소스 검색

医生端配置优化
sop同步saas

lk 15 시간 전
부모
커밋
fdbefb9fb7

+ 56 - 0
agentui/src/api/inventoryTransfer.js

@@ -0,0 +1,56 @@
+import request from '@/utils/request'
+
+// 查询库存调拨单列表
+export function listInventoryTransfer(query) {
+  return request({ url: '/supplychain/inventoryTransfer/list', method: 'get', params: query })
+}
+
+// 查询库存调拨单详情
+export function getInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/detail', method: 'get', params: { transferNo } })
+}
+
+// 新增库存调拨单(草稿)
+export function addInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer', method: 'post', data })
+}
+
+// 修改库存调拨单
+export function updateInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer', method: 'put', data })
+}
+
+// 删除库存调拨单
+export function delInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/' + transferNo, method: 'delete' })
+}
+
+// 提交库存调拨单
+export function submitInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/submit', method: 'post', params: { transferNo } })
+}
+
+// 审批库存调拨单
+export function auditInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer/audit', method: 'post', data })
+}
+
+// 发货确认
+export function shipInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/ship', method: 'post', params: { transferNo } })
+}
+
+// 收货确认
+export function receiveInventoryTransfer(data) {
+  return request({ url: '/supplychain/inventoryTransfer/receive', method: 'post', data })
+}
+
+// 取消库存调拨单
+export function cancelInventoryTransfer(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/cancel', method: 'post', params: { transferNo } })
+}
+
+// 查询操作日志
+export function listInventoryTransferLogs(transferNo) {
+  return request({ url: '/supplychain/inventoryTransfer/logs', method: 'get', params: { transferNo } })
+}

+ 16 - 1
agentui/src/router/index.js

@@ -180,6 +180,21 @@ export const constantRoutes = [
       }
     ]
   },
+  {
+    path: '/supplychain',
+    component: () => import('@/layout/index'),
+    redirect: '/supplychain/inventoryTransfer',
+    name: 'SupplyChain',
+    meta: { title: '供应链管理', icon: 'el-icon-s-operation' },
+    children: [
+      {
+        path: 'inventoryTransfer',
+        name: 'InventoryTransfer',
+        component: () => import('@/views/supplychain/inventoryTransfer/index'),
+        meta: { title: '库存调拨', icon: 'el-icon-truck' }
+      }
+    ]
+  },
   // 404 catch-all 必须放在最后,防止未匹配路由导致异常重定向
   { path: '*', redirect: '/404', hidden: true }
 ]
@@ -205,4 +220,4 @@ export function resetRouter() {
   router.matcher = newRouter.matcher
 }
 
-export default router
+export default router

+ 391 - 0
agentui/src/views/supplychain/inventoryTransfer/index.vue

@@ -0,0 +1,391 @@
+<template>
+  <div class="inventory-transfer-container">
+    <el-card shadow="never" class="mb16 filter-card">
+      <el-form :model="queryParams" ref="queryForm" :inline="true" size="small" class="list-search-form">
+        <el-form-item label="调拨单号" prop="transferNo">
+          <el-input v-model="queryParams.transferNo" placeholder="请输入调拨单号" clearable @keyup.enter.native="handleQuery" />
+        </el-form-item>
+        <el-form-item label="状态" prop="status">
+          <el-select v-model="queryParams.status" placeholder="全部" clearable style="width:140px">
+            <el-option v-for="(v, k) in statusMap" :key="k" :label="v" :value="k" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
+          <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <div class="mb8">
+      <el-button type="primary" icon="el-icon-plus" size="mini" @click="openEdit(null)">新增调拨单</el-button>
+    </div>
+
+    <el-table border v-loading="loading" :data="list" size="small" style="width:100%">
+      <el-table-column label="调拨单号" prop="transferNo" min-width="160" show-overflow-tooltip />
+      <el-table-column label="调出门店" prop="fromStoreName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="调入门店" prop="toStoreName" min-width="120" show-overflow-tooltip />
+      <el-table-column label="总数量" prop="totalQuantity" min-width="80" align="center" />
+      <el-table-column label="总金额" prop="totalAmount" min-width="90" align="center" />
+      <el-table-column label="状态" align="center" min-width="90">
+        <template slot-scope="scope">
+          <el-tag :type="statusTagType(scope.row.status)" size="mini">{{ scope.row.statusName || statusMap[scope.row.status] || scope.row.status }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="申请人" prop="applyUserName" min-width="90" align="center" />
+      <el-table-column label="申请时间" prop="applyTime" min-width="150" align="center" />
+      <el-table-column label="操作" align="center" width="280" fixed="right">
+        <template slot-scope="scope">
+          <el-button size="mini" type="text" icon="el-icon-view" @click="openDetail(scope.row)">详情</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" icon="el-icon-edit" @click="openEdit(scope.row)">编辑</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" icon="el-icon-s-promotion" @click="handleSubmit(scope.row)">提交</el-button>
+          <el-button v-if="scope.row.status === 'SUBMITTED'" size="mini" type="text" icon="el-icon-check" @click="openAudit(scope.row)">审批</el-button>
+          <el-button v-if="scope.row.status === 'APPROVED'" size="mini" type="text" icon="el-icon-truck" @click="handleShip(scope.row)">发货</el-button>
+          <el-button v-if="scope.row.status === 'IN_TRANSIT'" size="mini" type="text" icon="el-icon-box" @click="openReceive(scope.row)">收货</el-button>
+          <el-button v-if="['DRAFT', 'SUBMITTED'].includes(scope.row.status)" size="mini" type="text" icon="el-icon-close" @click="handleCancel(scope.row)">取消</el-button>
+          <el-button v-if="scope.row.status === 'DRAFT'" size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      v-show="total>0"
+      :total="total"
+      :current-page="queryParams.pageNum"
+      :page-size="queryParams.pageSize"
+      layout="total, sizes, prev, pager, next, jumper"
+      @size-change="handleSizeChange"
+      @current-change="handleCurrentChange"
+    />
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="editTitle" :visible.sync="editVisible" width="760px" append-to-body @closed="resetEdit">
+      <el-form ref="editForm" :model="editForm" :rules="editRules" label-width="100px">
+        <el-row :gutter="10">
+          <el-col :span="12">
+            <el-form-item label="调出门店ID" prop="fromStoreId">
+              <el-input-number v-model="editForm.fromStoreId" :min="1" style="width:100%" placeholder="请输入调出门店ID" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调出门店名称" prop="fromStoreName">
+              <el-input v-model="editForm.fromStoreName" placeholder="请输入调出门店名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调入门店ID" prop="toStoreId">
+              <el-input-number v-model="editForm.toStoreId" :min="1" style="width:100%" placeholder="请输入调入门店ID" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="调入门店名称" prop="toStoreName">
+              <el-input v-model="editForm.toStoreName" placeholder="请输入调入门店名称" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="editForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
+        </el-form-item>
+        <el-divider content-position="left">调拨明细</el-divider>
+        <div v-for="(item, idx) in editForm.items" :key="idx" class="item-row">
+          <el-input-number v-model="item.productId" :min="1" size="small" placeholder="商品ID" style="width:100px" />
+          <el-input v-model="item.productName" size="small" placeholder="商品名称" style="width:160px" />
+          <el-input v-model="item.productSpec" size="small" placeholder="规格" style="width:110px" />
+          <el-input v-model="item.unit" size="small" placeholder="单位" style="width:70px" />
+          <el-input-number v-model="item.quantity" :min="1" size="small" placeholder="数量" style="width:90px" />
+          <el-input-number v-model="item.costPrice" :min="0" :precision="2" size="small" placeholder="成本价" style="width:110px" />
+          <el-button size="mini" type="text" style="color:#f5222d" icon="el-icon-delete" @click="removeItem(idx)" />
+        </div>
+        <el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addItem">添加明细</el-button>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="editVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitEdit">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 详情弹窗 -->
+    <el-dialog title="调拨单详情" :visible.sync="detailVisible" width="860px" append-to-body>
+      <el-descriptions :column="3" border size="small">
+        <el-descriptions-item label="调拨单号">{{ detail.transferNo }}</el-descriptions-item>
+        <el-descriptions-item label="状态">{{ detail.statusName || statusMap[detail.status] || detail.status }}</el-descriptions-item>
+        <el-descriptions-item label="总金额">{{ detail.totalAmount }}</el-descriptions-item>
+        <el-descriptions-item label="调出门店">{{ detail.fromStoreName }}</el-descriptions-item>
+        <el-descriptions-item label="调入门店">{{ detail.toStoreName }}</el-descriptions-item>
+        <el-descriptions-item label="总数量">{{ detail.totalQuantity }}</el-descriptions-item>
+        <el-descriptions-item label="申请人">{{ detail.applyUserName }}</el-descriptions-item>
+        <el-descriptions-item label="申请时间">{{ detail.applyTime }}</el-descriptions-item>
+        <el-descriptions-item label="备注">{{ detail.remark }}</el-descriptions-item>
+      </el-descriptions>
+      <el-divider content-position="left">明细</el-divider>
+      <el-table border :data="detail.items || []" size="small">
+        <el-table-column label="商品ID" prop="productId" min-width="80" align="center" />
+        <el-table-column label="商品名称" prop="productName" min-width="150" show-overflow-tooltip />
+        <el-table-column label="规格" prop="productSpec" min-width="100" />
+        <el-table-column label="单位" prop="unit" min-width="60" align="center" />
+        <el-table-column label="数量" prop="quantity" min-width="70" align="center" />
+        <el-table-column label="成本价" prop="costPrice" min-width="80" align="center" />
+        <el-table-column label="小计" prop="totalAmount" min-width="80" align="center" />
+      </el-table>
+      <el-divider content-position="left">操作日志</el-divider>
+      <el-table border :data="logs" size="small" v-loading="logsLoading">
+        <el-table-column label="操作" prop="operationName" min-width="100" />
+        <el-table-column label="操作人" prop="operatorName" min-width="100" />
+        <el-table-column label="内容" prop="content" min-width="200" show-overflow-tooltip />
+        <el-table-column label="时间" prop="createTime" min-width="150" />
+      </el-table>
+    </el-dialog>
+
+    <!-- 审批弹窗 -->
+    <el-dialog title="审批调拨单" :visible.sync="auditVisible" width="460px" append-to-body>
+      <el-form ref="auditForm" :model="auditForm" :rules="auditRules" label-width="100px">
+        <el-form-item label="调拨单号">
+          <el-input :value="auditForm.transferNo" disabled />
+        </el-form-item>
+        <el-form-item label="审批结果" prop="approveResult">
+          <el-radio-group v-model="auditForm.approveResult">
+            <el-radio label="APPROVED">通过</el-radio>
+            <el-radio label="REJECTED">驳回</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="审批备注" prop="approveRemark">
+          <el-input v-model="auditForm.approveRemark" type="textarea" :rows="3" placeholder="请输入审批备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="auditVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitAudit">确 定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 收货弹窗 -->
+    <el-dialog title="收货确认" :visible.sync="receiveVisible" width="460px" append-to-body>
+      <el-form ref="receiveForm" :model="receiveForm" label-width="100px">
+        <el-form-item label="调拨单号">
+          <el-input :value="receiveForm.transferNo" disabled />
+        </el-form-item>
+        <el-form-item label="收货备注" prop="receiveRemark">
+          <el-input v-model="receiveForm.receiveRemark" type="textarea" :rows="3" placeholder="请输入收货备注" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="receiveVisible = false">取 消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitReceive">确 定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  listInventoryTransfer, getInventoryTransfer, addInventoryTransfer, updateInventoryTransfer,
+  delInventoryTransfer, submitInventoryTransfer, auditInventoryTransfer, shipInventoryTransfer,
+  receiveInventoryTransfer, cancelInventoryTransfer, listInventoryTransferLogs
+} from '@/api/inventoryTransfer'
+
+export default {
+  name: 'InventoryTransfer',
+  data() {
+    return {
+      loading: false,
+      list: [],
+      total: 0,
+      queryParams: { pageNum: 1, pageSize: 10, transferNo: null, status: null },
+      statusMap: {
+        DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', REJECTED: '已驳回',
+        IN_TRANSIT: '运输中', RECEIVED: '已收货', CANCELLED: '已取消'
+      },
+      editVisible: false,
+      editTitle: '',
+      submitting: false,
+      editForm: { id: null, transferNo: null, fromStoreId: null, fromStoreName: '', toStoreId: null, toStoreName: '', remark: '', items: [] },
+      editRules: {
+        fromStoreId: [{ required: true, message: '请输入调出门店ID', trigger: 'blur' }],
+        toStoreId: [{ required: true, message: '请输入调入门店ID', trigger: 'blur' }]
+      },
+      detailVisible: false,
+      detail: {},
+      logs: [],
+      logsLoading: false,
+      auditVisible: false,
+      auditForm: { transferNo: '', approveResult: 'APPROVED', approveRemark: '' },
+      auditRules: { approveResult: [{ required: true, message: '请选择审批结果', trigger: 'change' }] },
+      receiveVisible: false,
+      receiveForm: { transferNo: '', receiveRemark: '' }
+    }
+  },
+  created() {
+    this.getList()
+  },
+  methods: {
+    getList() {
+      this.loading = true
+      listInventoryTransfer(this.queryParams).then(r => {
+        this.list = r.rows || []
+        this.total = r.total || 0
+        this.loading = false
+      }).catch(() => { this.loading = false })
+    },
+    handleQuery() {
+      this.queryParams.pageNum = 1
+      this.getList()
+    },
+    resetQuery() {
+      this.queryParams = { pageNum: 1, pageSize: 10, transferNo: null, status: null }
+      if (this.$refs.queryForm) this.$refs.queryForm.resetFields()
+      this.getList()
+    },
+    handleSizeChange(val) {
+      this.queryParams.pageSize = val
+      this.getList()
+    },
+    handleCurrentChange(val) {
+      this.queryParams.pageNum = val
+      this.getList()
+    },
+    statusTagType(status) {
+      const map = {
+        DRAFT: 'info', SUBMITTED: 'warning', APPROVED: 'success', REJECTED: 'danger',
+        IN_TRANSIT: 'primary', RECEIVED: 'success', CANCELLED: 'info'
+      }
+      return map[status] || 'info'
+    },
+    addItem() {
+      this.editForm.items.push({ productId: null, productName: '', productSpec: '', unit: '', quantity: 1, costPrice: 0 })
+    },
+    removeItem(idx) {
+      this.editForm.items.splice(idx, 1)
+    },
+    openEdit(row) {
+      this.resetEdit()
+      this.addItem()
+      if (row) {
+        this.editTitle = '编辑调拨单'
+        getInventoryTransfer(row.transferNo).then(r => {
+          const d = r.data || {}
+          this.editForm = {
+            id: d.id, transferNo: d.transferNo, fromStoreId: d.fromStoreId, fromStoreName: d.fromStoreName,
+            toStoreId: d.toStoreId, toStoreName: d.toStoreName, remark: d.remark,
+            items: (d.items || []).map(it => ({
+              productId: it.productId, productName: it.productName, productSpec: it.productSpec,
+              unit: it.unit, quantity: it.quantity, costPrice: it.costPrice
+            }))
+          }
+          if (this.editForm.items.length === 0) this.addItem()
+          this.editVisible = true
+        })
+      } else {
+        this.editTitle = '新增调拨单'
+        this.editVisible = true
+      }
+    },
+    submitEdit() {
+      this.$refs.editForm.validate(valid => {
+        if (!valid) return
+        const items = this.editForm.items.filter(it => it.productId)
+        if (items.length === 0) {
+          this.$message.warning('请至少添加一条调拨明细')
+          return
+        }
+        const payload = { ...this.editForm, items }
+        this.submitting = true
+        const req = this.editForm.id ? updateInventoryTransfer(payload) : addInventoryTransfer(payload)
+        req.then(() => {
+          this.$message.success(this.editForm.id ? '修改成功' : '新增成功')
+          this.editVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    resetEdit() {
+      this.editForm = { id: null, transferNo: null, fromStoreId: null, fromStoreName: '', toStoreId: null, toStoreName: '', remark: '', items: [] }
+      if (this.$refs.editForm) this.$refs.editForm.resetFields()
+    },
+    openDetail(row) {
+      this.detail = {}
+      this.logs = []
+      this.detailVisible = true
+      getInventoryTransfer(row.transferNo).then(r => {
+        this.detail = r.data || {}
+      })
+      this.loadLogs(row.transferNo)
+    },
+    loadLogs(transferNo) {
+      this.logsLoading = true
+      listInventoryTransferLogs(transferNo).then(r => {
+        this.logs = r.data || []
+        this.logsLoading = false
+      }).catch(() => { this.logsLoading = false })
+    },
+    handleSubmit(row) {
+      this.$confirm(`确认提交调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        submitInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('提交成功')
+          this.getList()
+        })
+      }).catch(() => {})
+    },
+    openAudit(row) {
+      this.auditForm = { transferNo: row.transferNo, approveResult: 'APPROVED', approveRemark: '' }
+      this.auditVisible = true
+    },
+    submitAudit() {
+      this.$refs.auditForm.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        auditInventoryTransfer(this.auditForm).then(() => {
+          this.$message.success('审批完成')
+          this.auditVisible = false
+          this.getList()
+        }).finally(() => { this.submitting = false })
+      })
+    },
+    handleShip(row) {
+      this.$confirm(`确认对调拨单 "${row.transferNo}" 执行发货?`, '提示', { type: 'warning' }).then(() => {
+        shipInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('发货成功')
+          this.getList()
+        })
+      }).catch(() => {})
+    },
+    openReceive(row) {
+      this.receiveForm = { transferNo: row.transferNo, receiveRemark: '' }
+      this.receiveVisible = true
+    },
+    submitReceive() {
+      this.submitting = true
+      receiveInventoryTransfer(this.receiveForm).then(() => {
+        this.$message.success('收货成功')
+        this.receiveVisible = false
+        this.getList()
+      }).finally(() => { this.submitting = false })
+    },
+    handleCancel(row) {
+      this.$confirm(`确认取消调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        cancelInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('取消成功')
+          this.getList()
+        })
+      }).catch(() => {})
+    },
+    handleDelete(row) {
+      this.$confirm(`确认删除调拨单 "${row.transferNo}"?`, '提示', { type: 'warning' }).then(() => {
+        delInventoryTransfer(row.transferNo).then(() => {
+          this.$message.success('删除成功')
+          this.getList()
+        })
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.inventory-transfer-container {
+  padding: 20px;
+}
+.mb8 { margin-bottom: 8px; }
+.mb16 { margin-bottom: 16px; }
+.filter-card { padding-bottom: 0; }
+.item-row { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
+</style>

+ 11 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/qw/QwSopController.java

@@ -80,6 +80,17 @@ public class QwSopController extends BaseController
     @Autowired
     private IQwSopTempVoiceService voiceService;
 
+    @Autowired
+    private com.fs.sop.service.SopContentCapabilityService sopContentCapabilityService;
+
+    /**
+     * 租户 SOP 内容能力(群发/课程/直播)
+     */
+    @GetMapping("/contentCapabilities")
+    public AjaxResult contentCapabilities() {
+        return AjaxResult.success(sopContentCapabilityService.resolveCapabilities());
+    }
+
     /**
      * 查询企微sop列表
      */

+ 12 - 0
java/fs-saas-company/src/main/java/com/fs/company/controller/qw/QwSopTempController.java

@@ -15,6 +15,7 @@ import com.fs.company.service.impl.CompanyUserServiceImpl;
 import com.fs.company.vo.DocCompanyUserVO;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
+import com.fs.sop.service.SopContentCapabilityService;
 import com.fs.system.service.ISysConfigService;
 import com.fs.tenant.domain.TenantInfo;
 import com.fs.tenant.service.TenantInfoService;
@@ -72,6 +73,9 @@ public class QwSopTempController extends BaseController
     @Autowired
     private CompanyUserServiceImpl companyUserService;
 
+    @Autowired
+    private SopContentCapabilityService sopContentCapabilityService;
+
     /** 解析当前请求租户ID:token 优先,tenant-code 请求头兜底 */
     private Long resolveRequestTenantId(LoginUser loginUser) {
         if (loginUser != null && loginUser.getTenantId() != null) {
@@ -341,6 +345,7 @@ public class QwSopTempController extends BaseController
         LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
         if (qwSopTemp.getCompanyId() == null && loginUser.getCompany() != null) { qwSopTemp.setCompanyId(loginUser.getCompany().getCompanyId()); }
         qwSopTemp.setCreateBy(loginUser.getUser().getUserId().toString());
+        sopContentCapabilityService.assertCanUseSendType(qwSopTemp.getSendType());
         int i = qwSopTempService.addNew(qwSopTemp);
         if(qwSopTemp.getSendType() == 11){
             //筛选选课程数据
@@ -373,6 +378,13 @@ public class QwSopTempController extends BaseController
     @Log(title = "addOrUpdateSop模板规则", businessType = BusinessType.UPDATE)
     @PostMapping("/addOrUpdateSetting")
     public AjaxResult addOrUpdateSetting(@RequestBody QwSopTempDay day){
+        if (day != null && day.getList() != null && !day.getList().isEmpty()) {
+            String tempId = day.getTempId();
+            if (tempId == null && day.getList().get(0) != null) {
+                tempId = day.getList().get(0).getTempId();
+            }
+            sopContentCapabilityService.assertRulesMatchTemp(tempId, day.getList());
+        }
         return AjaxResult.success(qwSopTempService.addOrUpdateSetting(day));
     }
 

+ 8 - 0
java/fs-service/src/main/java/com/fs/company/mapper/CompanyMenuMapper.java

@@ -29,6 +29,14 @@ public interface CompanyMenuMapper
      */
     public List<CompanyMenu> selectCompanyMenuList(CompanyMenu companyMenu);
 
+    /**
+     * 租户级销售端模块是否已开通(company_menu,与当前登录人角色菜单无关)
+     *
+     * @param path 模块 path,如 course / live
+     * @return 命中条数
+     */
+    int countEnabledModuleByPath(@Param("path") String path);
+
     /**
      * 新增菜单权限
      * 

+ 179 - 0
java/fs-service/src/main/java/com/fs/sop/service/SopContentCapabilityService.java

@@ -0,0 +1,179 @@
+package com.fs.sop.service;
+
+import com.fs.common.exception.ServiceException;
+import com.fs.company.mapper.CompanyMenuMapper;
+import com.fs.sop.domain.QwSopTemp;
+import com.fs.sop.domain.QwSopTempRules;
+import com.fs.sop.mapper.QwSopTempMapper;
+import com.fs.sop.vo.SopContentCapabilitiesVO;
+import com.fs.system.mapper.SysMenuMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+/**
+ * SOP 三类推送能力判定与校验。
+ * <p>模板/任务 sendType:2=群发,11=课程,20=直播模板类型(与日志通道 sendType=20 同名不同字段)。</p>
+ * <p>能力按<strong>租户是否开通</strong>模块判定(sys_menu / company_menu 的 path 且 visible/status 启用),
+ * 与当前登录销售是否被分配该菜单无关。</p>
+ */
+@Service
+public class SopContentCapabilityService {
+
+    /** 群发助手 */
+    public static final int SEND_TYPE_MASS = 2;
+    /** 课程模板 */
+    public static final int SEND_TYPE_COURSE = 11;
+    /** 直播模板(模板/任务类型) */
+    public static final int SEND_TYPE_LIVE = 20;
+
+    /** 消息类别:普通 */
+    public static final int CONTENT_TYPE_NORMAL = 1;
+    /** 消息类别:课程 */
+    public static final int CONTENT_TYPE_COURSE = 2;
+    /** 消息类别:直播间 */
+    public static final int CONTENT_TYPE_LIVE = 20;
+
+    @Autowired
+    private CompanyMenuMapper companyMenuMapper;
+
+    @Autowired
+    private SysMenuMapper sysMenuMapper;
+
+    @Autowired
+    private QwSopTempMapper qwSopTempMapper;
+
+    /**
+     * 按租户是否开通 course/live 模块判定能力(不看当前用户角色菜单)
+     */
+    public SopContentCapabilitiesVO resolveCapabilities() {
+        SopContentCapabilitiesVO vo = new SopContentCapabilitiesVO();
+        vo.setEnableMass(true);
+        vo.setEnableCourse(hasTenantModule("course"));
+        vo.setEnableLive(hasTenantModule("live"));
+        return vo;
+    }
+
+    /**
+     * 租户级模块是否开通:总后台 sys_menu 或销售端 company_menu 任一已启用即可。
+     * 不关联 role_menu,避免「租户已开通但销售未分配菜单」时无法新建课程/直播 SOP。
+     */
+    private boolean hasTenantModule(String path) {
+        if (!StringUtils.hasText(path)) {
+            return false;
+        }
+        int sysCount = sysMenuMapper.countEnabledModuleByPath(path);
+        if (sysCount > 0) {
+            return true;
+        }
+        return companyMenuMapper.countEnabledModuleByPath(path) > 0;
+    }
+
+    /**
+     * 模板/任务 sendType -> 规则消息类别 type
+     */
+    public static int mapSendTypeToContentType(Integer sendType) {
+        if (sendType == null) {
+            return CONTENT_TYPE_NORMAL;
+        }
+        if (sendType == SEND_TYPE_COURSE) {
+            return CONTENT_TYPE_COURSE;
+        }
+        if (sendType == SEND_TYPE_LIVE) {
+            return CONTENT_TYPE_LIVE;
+        }
+        return CONTENT_TYPE_NORMAL;
+    }
+
+    /**
+     * 创建模板/任务时校验 sendType 是否允许
+     */
+    public void assertCanUseSendType(Integer sendType) {
+        if (sendType == null) {
+            throw new ServiceException("推送方式不能为空");
+        }
+        SopContentCapabilitiesVO caps = resolveCapabilities();
+        if (sendType == SEND_TYPE_COURSE && !caps.isEnableCourse()) {
+            throw new ServiceException("当前租户未开通课程能力,无法创建课程模板/任务");
+        }
+        if (sendType == SEND_TYPE_LIVE && !caps.isEnableLive()) {
+            throw new ServiceException("当前租户未开通直播能力,无法创建直播模板/任务");
+        }
+        if (sendType != SEND_TYPE_MASS && sendType != SEND_TYPE_COURSE && sendType != SEND_TYPE_LIVE
+                && sendType != 1 && sendType != 4 && sendType != 5) {
+            // 存量特殊类型放行编辑;新增入口前端已限制
+            return;
+        }
+    }
+
+    /**
+     * 任务与模板 sendType 必须一致
+     */
+    public void assertSopTempSendTypeMatch(Integer sopSendType, String tempId) {
+        if (tempId == null || tempId.isEmpty()) {
+            throw new ServiceException("模板不能为空");
+        }
+        QwSopTemp temp = qwSopTempMapper.selectById(tempId);
+        if (temp == null) {
+            throw new ServiceException("模板不存在");
+        }
+        if (sopSendType != null && temp.getSendType() != null
+                && !sopSendType.equals(temp.getSendType())) {
+            throw new ServiceException("推送方式与所选模板类型不一致");
+        }
+        assertCanUseSendType(temp.getSendType());
+    }
+
+    /**
+     * 保存规则时:消息类别须与模板类型锁定一致(存量 AI/打标签仅群发模板下允许保留)
+     */
+    public void assertRulesMatchTemp(String tempId, List<QwSopTempRules> rules) {
+        if (tempId == null || CollectionUtils.isEmpty(rules)) {
+            return;
+        }
+        QwSopTemp temp = qwSopTempMapper.selectById(tempId);
+        if (temp == null) {
+            throw new ServiceException("模板不存在");
+        }
+        Integer sendType = temp.getSendType();
+        assertCanUseSendType(sendType);
+        int expected = mapSendTypeToContentType(sendType);
+        for (QwSopTempRules rule : rules) {
+            if (rule == null || rule.getType() == null) {
+                continue;
+            }
+            int type = rule.getType();
+            // 群发模板允许存量 AI/打标签
+            if (sendType != null && sendType == SEND_TYPE_MASS
+                    && (type == 4 || type == 5 || type == CONTENT_TYPE_NORMAL)) {
+                continue;
+            }
+            if (type != expected) {
+                throw new ServiceException("消息类别须与模板推送方式一致,请勿混用课程/直播/普通");
+            }
+            if (type == CONTENT_TYPE_COURSE && !resolveCapabilities().isEnableCourse()) {
+                throw new ServiceException("当前租户未开通课程能力");
+            }
+            if (type == CONTENT_TYPE_LIVE && !resolveCapabilities().isEnableLive()) {
+                throw new ServiceException("当前租户未开通直播能力");
+            }
+        }
+    }
+
+    /**
+     * 日志生成时:无能力则跳过对应消息类别
+     */
+    public boolean shouldSkipContentType(int contentType) {
+        SopContentCapabilitiesVO caps = resolveCapabilities();
+        if (contentType == CONTENT_TYPE_COURSE && !caps.isEnableCourse()) {
+            return true;
+        }
+        if (contentType == CONTENT_TYPE_LIVE && !caps.isEnableLive()) {
+            return true;
+        }
+        return false;
+    }
+}

+ 9 - 0
java/fs-service/src/main/java/com/fs/sop/service/impl/QwSopServiceImpl.java

@@ -91,6 +91,9 @@ public class QwSopServiceImpl implements IQwSopService
     @Autowired
     private QwSopTempMapper qwSopTempMapper;
 
+    @Autowired
+    private SopContentCapabilityService sopContentCapabilityService;
+
     @Autowired
     private IFsCourseLinkService iFsCourseLinkService;
 
@@ -197,10 +200,15 @@ public class QwSopServiceImpl implements IQwSopService
     
     public int insertQwSop(QwSop qwSop)
     {
+        // 推送方式与模板类型一致,并校验租户能力
+        sopContentCapabilityService.assertSopTempSendTypeMatch(qwSop.getSendType(), qwSop.getTempId());
         SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
         qwSop.setCreateTime(sdf.format(new Date()));
         QwSopTemp qwSopTemp = qwSopTempMapper.selectById(qwSop.getTempId());
         qwSop.setProject(qwSopTemp.getProject());
+        if (qwSop.getSendType() == null && qwSopTemp.getSendType() != null) {
+            qwSop.setSendType(qwSopTemp.getSendType());
+        }
 //        if(qwSop.getAutoGroup() == 1){
 //            qwSop.setPullTime(qwSop.getStartTime());
 //        }
@@ -227,6 +235,7 @@ public class QwSopServiceImpl implements IQwSopService
             if (StringUtil.strIsNullOrEmpty(qwSop.getId())||StringUtil.strIsNullOrEmpty(qwSop.getTempId())){
                 return R.error("sop编号或模板编号不能为空");
             }
+            sopContentCapabilityService.assertSopTempSendTypeMatch(qwSop.getSendType(), qwSop.getTempId());
 
             int i = qwSopMapper.updateQwSop(qwSop);
             if (i > 0) {

+ 23 - 0
java/fs-service/src/main/java/com/fs/sop/vo/SopContentCapabilitiesVO.java

@@ -0,0 +1,23 @@
+package com.fs.sop.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 租户 SOP 内容能力(群发 / 课程 / 直播)
+ */
+@Data
+public class SopContentCapabilitiesVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 群发(始终 true) */
+    private boolean enableMass = true;
+
+    /** 课程模块是否对租户开放 */
+    private boolean enableCourse;
+
+    /** 直播模块是否对租户开放 */
+    private boolean enableLive;
+}

+ 8 - 0
java/fs-service/src/main/java/com/fs/system/mapper/SysMenuMapper.java

@@ -19,6 +19,14 @@ public interface SysMenuMapper
      */
     public List<SysMenu> selectMenuList(SysMenu menu);
 
+    /**
+     * 租户级模块是否已开通(sys_menu,与当前登录人角色菜单无关)
+     *
+     * @param path 模块 path,如 course / live
+     * @return 命中条数
+     */
+    int countEnabledModuleByPath(@Param("path") String path);
+
     /**
      * 根据用户所有权限
      *

+ 15 - 6
java/fs-service/src/main/resources/mapper/company/CompanyMenuMapper.xml

@@ -3,7 +3,7 @@
 PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.fs.company.mapper.CompanyMenuMapper">
-    
+
     <resultMap type="CompanyMenu" id="CompanyMenuResult">
         <result property="menuId"    column="menu_id"    />
         <result property="menuName"    column="menu_name"    />
@@ -31,7 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 
     <select id="selectCompanyMenuList" resultMap="CompanyMenuResult">
         <include refid="selectCompanyMenuVo"/>
-        <where>  
+        <where>
             <if test="menuName != null  and menuName != ''"> and menu_name like concat('%', #{menuName}, '%')</if>
             <if test="parentId != null "> and parent_id = #{parentId}</if>
             <if test="orderNum != null "> and order_num = #{orderNum}</if>
@@ -47,12 +47,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
         order by order_num asc
     </select>
-    
+
+    <!-- 租户级能力:仅看模块是否对租户开放,不关联 company_role_menu / 当前销售 -->
+    <select id="countEnabledModuleByPath" resultType="int">
+        select count(1)
+        from company_menu
+        where path = #{path}
+          and visible = '0'
+          and status = '0'
+    </select>
+
     <select id="selectCompanyMenuById" resultMap="CompanyMenuResult">
         <include refid="selectCompanyMenuVo"/>
         where menu_id = #{menuId}
     </select>
-        
+
     <insert id="insertCompanyMenu" useGeneratedKeys="true" keyProperty="menuId">
         insert into company_menu
         <trim prefix="(" suffix=")" suffixOverrides=",">
@@ -124,7 +133,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </delete>
 
     <delete id="deleteCompanyMenuByIds">
-        delete from company_menu where menu_id in 
+        delete from company_menu where menu_id in
         <foreach item="menuId" collection="array" open="(" separator="," close=")">
             #{menuId}
         </foreach>
@@ -172,4 +181,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="hasChildByMenuId" resultType="Integer">
 	    select count(1) from company_menu where parent_id = #{menuId}
 	</select>
-</mapper>
+</mapper>

+ 22 - 14
java/fs-service/src/main/resources/mapper/system/SysMenuMapper.xml

@@ -28,10 +28,10 @@
 	</resultMap>
 
 	<sql id="selectMenuVo">
-        select menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, ifnull(perms,'') as perms, icon, create_time 
+        select menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, ifnull(perms,'') as perms, icon, create_time
 		from sys_menu
     </sql>
-    
+
     <select id="selectMenuList" resultMap="SysMenuResult">
 		<include refid="selectMenuVo"/>
 		<where>
@@ -47,13 +47,21 @@
 		</where>
 		order by parent_id, order_num
 	</select>
-	
+
+	<select id="countEnabledModuleByPath" resultType="int">
+		select count(1)
+		from sys_menu
+		where path = #{path}
+		  and visible = '0'
+		  and status = '0'
+	</select>
+
 	<select id="selectMenuTreeAll" resultMap="SysMenuResult">
 		select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.query, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
 		from sys_menu m where m.menu_type in ('M', 'C') and m.status = 0 and m.visible = '0'
 		order by m.parent_id, m.order_num
 	</select>
-	
+
 	<select id="selectMenuListByUserId" resultMap="SysMenuResult">
 		select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.query, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
 		from sys_menu m
@@ -72,7 +80,7 @@
 		</if>
 		order by m.parent_id, m.order_num
 	</select>
-    
+
     <select id="selectMenuTreeByUserId" resultMap="SysMenuResult">
 		select distinct m.menu_id, m.parent_id, m.menu_name, m.path, m.component, m.query, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.is_cache, m.menu_type, m.icon, m.order_num, m.create_time
 		from sys_menu m
@@ -83,7 +91,7 @@
 		where u.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = 0  AND ro.status = 0
 		order by m.parent_id, m.order_num
 	</select>
-	
+
 	<select id="selectMenuListByRoleId" resultType="Integer">
 		select m.menu_id
 		from sys_menu m
@@ -94,7 +102,7 @@
             </if>
 		order by m.parent_id, m.order_num
 	</select>
-	
+
 	<select id="selectMenuPerms" resultType="String">
 		select distinct m.perms
 		from sys_menu m
@@ -110,21 +118,21 @@
 			 left join sys_role r on r.role_id = ur.role_id
 		where m.status = '0' and r.status = '0' and ur.user_id = #{userId}
 	</select>
-	
+
 	<select id="selectMenuById" resultMap="SysMenuResult">
 		<include refid="selectMenuVo"/>
 		where menu_id = #{menuId}
 	</select>
-	
+
 	<select id="hasChildByMenuId" resultType="Integer">
-	    select count(1) from sys_menu where parent_id = #{menuId}  
+	    select count(1) from sys_menu where parent_id = #{menuId}
 	</select>
-	
+
 	<select id="checkMenuNameUnique" resultMap="SysMenuResult">
 		<include refid="selectMenuVo"/>
 		where menu_name=#{menuName} and parent_id = #{parentId} limit 1
 	</select>
-	
+
 	<update id="updateMenu">
 		update sys_menu
 		<set>
@@ -187,9 +195,9 @@
 		sysdate()
 		)
 	</insert>
-	
+
 	<delete id="deleteMenuById">
 	    delete from sys_menu where menu_id = #{menuId}
 	</delete>
 
-</mapper> 
+</mapper>

+ 8 - 0
java/fs-task/src/main/java/com/fs/task/support/impl/SopLogsTaskServiceImpl.java

@@ -45,6 +45,7 @@ import com.fs.sop.service.IQwSopLogsService;
 import com.fs.sop.service.IQwSopTempContentService;
 import com.fs.sop.service.IQwSopTempRulesService;
 import com.fs.sop.service.IQwSopTempVoiceService;
+import com.fs.sop.service.SopContentCapabilityService;
 import com.fs.sop.vo.QwCreateLinkByAppVO;
 import com.fs.sop.vo.SopUserLogsVo;
 import com.fs.system.domain.SysConfig;
@@ -163,6 +164,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     @Autowired
     private IQwSopTempVoiceService qwSopTempVoiceService;
     @Autowired
+    private SopContentCapabilityService sopContentCapabilityService;
+    @Autowired
     private CloudHostProper cloudHostProper;
 
     // Blocking queues with bounded capacity to implement backpressure(携带 tenantId,供后台消费线程切库)
@@ -811,6 +814,11 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
         Long liveId = content.getLiveId();
         Integer isOfficial = content.getIsOfficial() != null ? Integer.valueOf(content.getIsOfficial()) : 0;
 
+        // 租户关闭课程/直播能力后,新日志不再生成对应类型
+        if (sopContentCapabilityService.shouldSkipContentType(type)) {
+            log.info("租户无对应能力,跳过消息类型 type={}, sopId={}", type, logVo != null ? logVo.getSopId() : null);
+            return;
+        }
 
         // 发送语音 start
         if(content.getSetting() == null){

+ 4 - 5
saasmgnui/src/views/course/videoResource/index.vue

@@ -1366,11 +1366,10 @@ export default {
       this.form.fileSize = 0;
       this.form.fileName = '';
       this.form.line1 = '';
-      this.form.line_2 = '';
-      this.form.line_3 = '';
-      this.uploadType = null
-      this.fileSize = null
-      this.fileKey = null
+      this.form.line2 = '';
+      this.form.line3 = '';
+      this.form.fileKey = null
+      this.form.hsyVid = null
     },
     //获取第一帧封面
     async getFirstThumbnail(file, form){

+ 5 - 0
ylrz-doctorui/.dockerignore

@@ -0,0 +1,5 @@
+node_modules
+.git
+.gitignore
+*.log
+.idea

+ 22 - 0
ylrz-doctorui/.editorconfig

@@ -0,0 +1,22 @@
+# 告诉EditorConfig插件,这是根文件,不用继续往上查找
+root = true
+
+# 匹配全部文件
+[*]
+# 设置字符集
+charset = utf-8
+# 缩进风格,可选space、tab
+indent_style = space
+# 缩进的空格数
+indent_size = 2
+# 结尾换行符,可选lf、cr、crlf
+end_of_line = lf
+# 在文件结尾插入新行
+insert_final_newline = true
+# 删除一行中的前后空格
+trim_trailing_whitespace = true
+
+# 匹配md结尾的文件
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false

+ 10 - 0
ylrz-doctorui/.eslintignore

@@ -0,0 +1,10 @@
+# 忽略build目录下类型为js的文件的语法检查
+build/*.js
+# 忽略src/assets目录下文件的语法检查
+src/assets
+# 忽略public目录下文件的语法检查
+public
+# 忽略当前目录下为js的文件的语法检查
+*.js
+# 忽略当前目录下为vue的文件的语法检查
+*.vue

+ 1 - 1
ylrz-doctorui/.eslintrc.js

@@ -18,4 +18,4 @@ module.exports = {
         // 可添加其他规则,或暂时关闭所有规则
         'vue/multi-word-component-names': 'off', // 关闭组件命名规则(可选)
     },
-};
+};

+ 26 - 0
ylrz-doctorui/.gitignore

@@ -0,0 +1,26 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+package-lock.json
+yarn.lock

+ 2 - 7
ylrz-doctorui/src/store/modules/user.js

@@ -9,7 +9,6 @@ const user = {
     avatar: '',
     roles: [],
     permissions: [],
-    routers: [],
     doctorType: null
   },
 
@@ -20,8 +19,7 @@ const user = {
     SET_USER: (state, user) => { state.user = user },
     SET_ROLES: (state, roles) => { state.roles = roles },
     SET_PERMISSIONS: (state, permissions) => { state.permissions = permissions },
-    SET_DOCTOR_TYPE: (state, doctorType) => { state.doctorType = doctorType },
-    SET_ROUTERS: (state, routers) => { state.routers = routers }
+    SET_DOCTOR_TYPE: (state, doctorType) => { state.doctorType = doctorType }
   },
 
   actions: {
@@ -40,7 +38,6 @@ const user = {
           commit('SET_ROLES', res.roles || [])
           commit('SET_PERMISSIONS', res.permissions || [])
           commit('SET_DOCTOR_TYPE', doctor ? doctor.doctorType : null)
-          commit('SET_ROUTERS', res.routers || [])
           resolve(res)
         }).catch(error => {
           reject(error)
@@ -59,8 +56,7 @@ const user = {
           commit('SET_ROLES', res.roles || [])
           commit('SET_PERMISSIONS', res.permissions || [])
           commit('SET_DOCTOR_TYPE', doctor ? doctor.doctorType : null)
-          commit('SET_ROUTERS', res.routers || [])
-          resolve({ user: doctor, roles: res.roles, permissions: res.permissions, routers: res.routers })
+          resolve({ user: doctor, roles: res.roles, permissions: res.permissions })
         }).catch(error => {
           reject(error)
         })
@@ -73,7 +69,6 @@ const user = {
         commit('SET_TOKEN', '')
         commit('SET_ROLES', [])
         commit('SET_PERMISSIONS', [])
-        commit('SET_ROUTERS', [])
         commit('SET_USER', undefined)
         commit('SET_DOCTOR_TYPE', null)
         removeToken()