订单异议开发

This commit is contained in:
朱昊天
2026-06-17 19:31:10 +08:00
parent d5736cd5f8
commit 27807c14eb
19 changed files with 2455 additions and 10 deletions

View File

@@ -21,6 +21,10 @@
<groupId>com.gear</groupId>
<artifactId>gear-common</artifactId>
</dependency>
<dependency>
<groupId>com.gear</groupId>
<artifactId>gear-flowable</artifactId>
</dependency>
<!-- 邮件发送相关依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,52 @@
package com.gear.oa.controller;
import com.gear.common.core.controller.BaseController;
import com.gear.common.core.domain.R;
import com.gear.oa.domain.bo.OrderDisputeFlowConfirmBo;
import com.gear.oa.domain.bo.OrderDisputeFlowStartBo;
import com.gear.oa.domain.vo.OrderDisputeFlowVo;
import com.gear.oa.domain.vo.OrderDisputeTaskListVo;
import com.gear.oa.domain.vo.OrderDisputeTodoTaskVo;
import com.gear.oa.service.IOrderDisputeFlowService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/oa/orderDisputeFlow")
public class OrderDisputeFlowController extends BaseController {
private final IOrderDisputeFlowService orderDisputeFlowService;
@GetMapping("/byOrder/{orderId}")
public R<OrderDisputeFlowVo> queryByOrderId(@PathVariable Long orderId) {
return R.ok(orderDisputeFlowService.queryByOrderId(orderId));
}
@PostMapping("/start")
public R<String> start(@RequestBody OrderDisputeFlowStartBo bo) {
return R.ok(orderDisputeFlowService.start(bo));
}
@PostMapping("/confirm")
public R<Void> confirm(@RequestBody OrderDisputeFlowConfirmBo bo) {
orderDisputeFlowService.confirm(bo);
return R.ok();
}
@GetMapping("/todoList")
public R<List<OrderDisputeTodoTaskVo>> todoList(@RequestParam(required = false) String stepKey) {
return R.ok(orderDisputeFlowService.todoList(stepKey));
}
@GetMapping("/taskList")
public R<List<OrderDisputeTaskListVo>> taskList(@RequestParam(required = false) String stepKey,
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer limit) {
return R.ok(orderDisputeFlowService.taskList(stepKey, status, limit));
}
}

View File

@@ -0,0 +1,12 @@
package com.gear.oa.domain.bo;
import lombok.Data;
@Data
public class OrderDisputeFlowConfirmBo {
private Long orderId;
private String comment;
}

View File

@@ -0,0 +1,26 @@
package com.gear.oa.domain.bo;
import lombok.Data;
@Data
public class OrderDisputeFlowStartBo {
private Long orderId;
private String orderCode;
private Long customerId;
private String customerName;
private Long acceptUserId;
private Long physicalUserId;
private Long analysisUserId;
private Long planUserId;
private Long visitUserId;
}

View File

@@ -0,0 +1,22 @@
package com.gear.oa.domain.vo;
import lombok.Data;
import java.util.Date;
@Data
public class OrderDisputeFlowStepVo {
private String stepKey;
private String stepName;
private String assignee;
private Date startTime;
private Date endTime;
private String comment;
}

View File

@@ -0,0 +1,38 @@
package com.gear.oa.domain.vo;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
public class OrderDisputeFlowVo {
private Long orderId;
private String orderCode;
private String customerName;
private String procInsId;
private String status;
private String currentTaskId;
private String currentTaskKey;
private String currentTaskName;
private String currentAssignee;
private Date startTime;
private Date endTime;
private Map<String, Object> variables;
private List<OrderDisputeFlowStepVo> steps;
}

View File

@@ -0,0 +1,31 @@
package com.gear.oa.domain.vo;
import lombok.Data;
import java.util.Date;
@Data
public class OrderDisputeTaskListVo {
private String taskId;
private String taskKey;
private String taskName;
private String procInsId;
private Long orderId;
private String orderCode;
private Long customerId;
private String customerName;
private Date createTime;
private Date endTime;
private String status;
}

View File

@@ -0,0 +1,28 @@
package com.gear.oa.domain.vo;
import lombok.Data;
import java.util.Date;
@Data
public class OrderDisputeTodoTaskVo {
private String taskId;
private String taskKey;
private String taskName;
private String procInsId;
private Long orderId;
private String orderCode;
private Long customerId;
private String customerName;
private Date createTime;
}

View File

@@ -0,0 +1,22 @@
package com.gear.oa.service;
import com.gear.oa.domain.bo.OrderDisputeFlowConfirmBo;
import com.gear.oa.domain.bo.OrderDisputeFlowStartBo;
import com.gear.oa.domain.vo.OrderDisputeFlowVo;
import com.gear.oa.domain.vo.OrderDisputeTaskListVo;
import com.gear.oa.domain.vo.OrderDisputeTodoTaskVo;
import java.util.List;
public interface IOrderDisputeFlowService {
OrderDisputeFlowVo queryByOrderId(Long orderId);
String start(OrderDisputeFlowStartBo bo);
void confirm(OrderDisputeFlowConfirmBo bo);
List<OrderDisputeTodoTaskVo> todoList(String stepKey);
List<OrderDisputeTaskListVo> taskList(String stepKey, String status, Integer limit);
}

View File

@@ -0,0 +1,410 @@
package com.gear.oa.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import com.gear.common.exception.ServiceException;
import com.gear.common.helper.LoginHelper;
import com.gear.common.utils.StringUtils;
import com.gear.flowable.common.enums.FlowComment;
import com.gear.oa.domain.bo.OrderDisputeFlowConfirmBo;
import com.gear.oa.domain.bo.OrderDisputeFlowStartBo;
import com.gear.oa.domain.vo.OrderDisputeFlowStepVo;
import com.gear.oa.domain.vo.OrderDisputeFlowVo;
import com.gear.oa.domain.vo.OrderDisputeTaskListVo;
import com.gear.oa.domain.vo.OrderDisputeTodoTaskVo;
import com.gear.oa.service.IOrderDisputeFlowService;
import com.gear.oa.workflow.OrderDisputeProcessDeployer;
import lombok.RequiredArgsConstructor;
import org.flowable.engine.HistoryService;
import org.flowable.engine.IdentityService;
import org.flowable.engine.RepositoryService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
import org.flowable.engine.history.HistoricProcessInstance;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.task.api.Task;
import org.flowable.task.api.TaskQuery;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.flowable.task.api.history.HistoricTaskInstanceQuery;
import org.flowable.engine.task.Comment;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class OrderDisputeFlowServiceImpl implements IOrderDisputeFlowService {
private final RepositoryService repositoryService;
private final RuntimeService runtimeService;
private final TaskService taskService;
private final HistoryService historyService;
private final IdentityService identityService;
@Override
public OrderDisputeFlowVo queryByOrderId(Long orderId) {
if (orderId == null) {
return null;
}
ProcessInstance active = runtimeService.createProcessInstanceQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.variableValueEquals("orderId", orderId)
.active()
.singleResult();
if (active != null) {
Map<String, Object> runtimeVars = runtimeService.getVariables(active.getProcessInstanceId());
return buildRunningFlowVo(orderId, active.getProcessInstanceId(), runtimeVars);
}
HistoricProcessInstance historic = historyService.createHistoricProcessInstanceQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.variableValueEquals("orderId", orderId)
.includeProcessVariables()
.orderByProcessInstanceStartTime()
.desc()
.listPage(0, 1)
.stream()
.findFirst()
.orElse(null);
if (historic == null) {
return null;
}
return buildFlowVo(orderId, historic);
}
@Override
public String start(OrderDisputeFlowStartBo bo) {
if (bo == null || bo.getOrderId() == null) {
throw new ServiceException("orderId不能为空");
}
Long orderId = bo.getOrderId();
ProcessInstance active = runtimeService.createProcessInstanceQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.variableValueEquals("orderId", orderId)
.active()
.singleResult();
if (active != null) {
return active.getProcessInstanceId();
}
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("未登录");
}
String userIdStr = String.valueOf(userId);
Map<String, Object> variables = new HashMap<>();
variables.put("orderId", orderId);
if (StringUtils.isNotBlank(bo.getOrderCode())) {
variables.put("orderCode", bo.getOrderCode());
}
if (bo.getCustomerId() != null) {
variables.put("customerId", bo.getCustomerId());
}
if (StringUtils.isNotBlank(bo.getCustomerName())) {
variables.put("customerName", bo.getCustomerName());
}
variables.put("acceptUserId", String.valueOf(bo.getAcceptUserId() != null ? bo.getAcceptUserId() : userId));
variables.put("physicalUserId", String.valueOf(bo.getPhysicalUserId() != null ? bo.getPhysicalUserId() : userId));
variables.put("analysisUserId", String.valueOf(bo.getAnalysisUserId() != null ? bo.getAnalysisUserId() : userId));
variables.put("planUserId", String.valueOf(bo.getPlanUserId() != null ? bo.getPlanUserId() : userId));
variables.put("visitUserId", String.valueOf(bo.getVisitUserId() != null ? bo.getVisitUserId() : userId));
identityService.setAuthenticatedUserId(userIdStr);
ProcessInstance instance = runtimeService.startProcessInstanceByKey(OrderDisputeProcessDeployer.PROCESS_KEY, variables);
return instance.getProcessInstanceId();
}
@Override
public void confirm(OrderDisputeFlowConfirmBo bo) {
if (bo == null || bo.getOrderId() == null) {
throw new ServiceException("orderId不能为空");
}
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("未登录");
}
String userIdStr = String.valueOf(userId);
ProcessInstance active = runtimeService.createProcessInstanceQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.variableValueEquals("orderId", bo.getOrderId())
.active()
.singleResult();
if (active == null) {
throw new ServiceException("异议流程不存在或已结束");
}
Task task = taskService.createTaskQuery()
.processInstanceId(active.getProcessInstanceId())
.active()
.singleResult();
if (task == null) {
throw new ServiceException("当前没有可处理的节点");
}
if (!StringUtils.equals(task.getAssignee(), userIdStr)) {
throw new ServiceException("当前节点不是你的待办");
}
identityService.setAuthenticatedUserId(userIdStr);
String comment = bo.getComment() != null ? String.valueOf(bo.getComment()) : "";
taskService.addComment(task.getId(), active.getProcessInstanceId(), FlowComment.NORMAL.getType(), comment);
taskService.complete(task.getId());
}
@Override
public List<OrderDisputeTodoTaskVo> todoList(String stepKey) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("未登录");
}
String userIdStr = String.valueOf(userId);
TaskQuery query = taskService.createTaskQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.active()
.includeProcessVariables()
.taskAssignee(userIdStr)
.orderByTaskCreateTime()
.desc();
if (StringUtils.isNotBlank(stepKey)) {
query.taskDefinitionKey(stepKey);
}
List<Task> list = query.list();
if (CollUtil.isEmpty(list)) {
return Collections.emptyList();
}
return list.stream().map(task -> {
Map<String, Object> vars = task.getProcessVariables();
OrderDisputeTodoTaskVo vo = new OrderDisputeTodoTaskVo();
vo.setTaskId(task.getId());
vo.setTaskKey(task.getTaskDefinitionKey());
vo.setTaskName(task.getName());
vo.setProcInsId(task.getProcessInstanceId());
vo.setCreateTime(task.getCreateTime());
vo.setOrderId(MapUtil.getLong(vars, "orderId"));
vo.setOrderCode(MapUtil.getStr(vars, "orderCode"));
vo.setCustomerId(MapUtil.getLong(vars, "customerId"));
vo.setCustomerName(MapUtil.getStr(vars, "customerName"));
return vo;
}).collect(Collectors.toList());
}
@Override
public List<OrderDisputeTaskListVo> taskList(String stepKey, String status, Integer limit) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("未登录");
}
String userIdStr = String.valueOf(userId);
String statusVal = StringUtils.isNotBlank(status) ? status.trim().toLowerCase() : "todo";
boolean includeTodo = "todo".equals(statusVal) || "all".equals(statusVal);
boolean includeDone = "done".equals(statusVal) || "all".equals(statusVal);
int limitVal = limit != null ? limit : 200;
if (limitVal <= 0) {
limitVal = 200;
}
if (limitVal > 500) {
limitVal = 500;
}
List<OrderDisputeTaskListVo> result = new ArrayList<>();
if (includeTodo) {
TaskQuery query = taskService.createTaskQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.active()
.includeProcessVariables()
.taskAssignee(userIdStr)
.orderByTaskCreateTime()
.desc();
if (StringUtils.isNotBlank(stepKey)) {
query.taskDefinitionKey(stepKey);
}
List<Task> todo = query.listPage(0, limitVal);
if (CollUtil.isNotEmpty(todo)) {
result.addAll(todo.stream().map(task -> {
Map<String, Object> vars = task.getProcessVariables();
OrderDisputeTaskListVo vo = new OrderDisputeTaskListVo();
vo.setTaskId(task.getId());
vo.setTaskKey(task.getTaskDefinitionKey());
vo.setTaskName(task.getName());
vo.setProcInsId(task.getProcessInstanceId());
vo.setCreateTime(task.getCreateTime());
vo.setOrderId(MapUtil.getLong(vars, "orderId"));
vo.setOrderCode(MapUtil.getStr(vars, "orderCode"));
vo.setCustomerId(MapUtil.getLong(vars, "customerId"));
vo.setCustomerName(MapUtil.getStr(vars, "customerName"));
vo.setStatus("TODO");
return vo;
}).collect(Collectors.toList()));
}
}
if (includeDone) {
HistoricTaskInstanceQuery query = historyService.createHistoricTaskInstanceQuery()
.processDefinitionKey(OrderDisputeProcessDeployer.PROCESS_KEY)
.finished()
.taskAssignee(userIdStr)
.includeProcessVariables()
.orderByHistoricTaskInstanceEndTime()
.desc();
if (StringUtils.isNotBlank(stepKey)) {
query.taskDefinitionKey(stepKey);
}
List<HistoricTaskInstance> done = query.listPage(0, limitVal);
if (CollUtil.isNotEmpty(done)) {
result.addAll(done.stream().map(task -> {
Map<String, Object> vars = task.getProcessVariables();
OrderDisputeTaskListVo vo = new OrderDisputeTaskListVo();
vo.setTaskId(task.getId());
vo.setTaskKey(task.getTaskDefinitionKey());
vo.setTaskName(task.getName());
vo.setProcInsId(task.getProcessInstanceId());
vo.setCreateTime(task.getStartTime());
vo.setEndTime(task.getEndTime());
vo.setOrderId(MapUtil.getLong(vars, "orderId"));
vo.setOrderCode(MapUtil.getStr(vars, "orderCode"));
vo.setCustomerId(MapUtil.getLong(vars, "customerId"));
vo.setCustomerName(MapUtil.getStr(vars, "customerName"));
vo.setStatus("DONE");
return vo;
}).collect(Collectors.toList()));
}
}
if (CollUtil.isEmpty(result)) {
return Collections.emptyList();
}
result.sort((a, b) -> {
if (a == null || b == null) {
return 0;
}
int statusCompare = 0;
if ("TODO".equals(a.getStatus()) && "DONE".equals(b.getStatus())) {
statusCompare = -1;
} else if ("DONE".equals(a.getStatus()) && "TODO".equals(b.getStatus())) {
statusCompare = 1;
}
if (statusCompare != 0) {
return statusCompare;
}
Date at = a.getCreateTime();
Date bt = b.getCreateTime();
if (at == null && bt == null) {
return 0;
}
if (at == null) {
return 1;
}
if (bt == null) {
return -1;
}
return bt.compareTo(at);
});
return result;
}
private OrderDisputeFlowVo buildFlowVo(Long orderId, HistoricProcessInstance historic) {
OrderDisputeFlowVo vo = new OrderDisputeFlowVo();
vo.setOrderId(orderId);
vo.setProcInsId(historic.getId());
vo.setStartTime(historic.getStartTime());
vo.setEndTime(historic.getEndTime());
vo.setStatus(historic.getEndTime() == null ? "RUNNING" : "FINISHED");
Map<String, Object> variables = historic.getProcessVariables() != null ? historic.getProcessVariables() : Collections.emptyMap();
applyVariables(vo, variables);
Task currentTask = taskService.createTaskQuery().processInstanceId(historic.getId()).active().singleResult();
if (currentTask != null) {
vo.setCurrentTaskId(currentTask.getId());
vo.setCurrentTaskKey(currentTask.getTaskDefinitionKey());
vo.setCurrentTaskName(currentTask.getName());
vo.setCurrentAssignee(currentTask.getAssignee());
}
vo.setSteps(buildSteps(historic.getId()));
return vo;
}
private OrderDisputeFlowVo buildRunningFlowVo(Long orderId, String procInsId, Map<String, Object> runtimeVars) {
OrderDisputeFlowVo vo = new OrderDisputeFlowVo();
vo.setOrderId(orderId);
vo.setProcInsId(procInsId);
vo.setStatus("RUNNING");
Map<String, Object> variables = runtimeVars != null ? runtimeVars : Collections.emptyMap();
applyVariables(vo, variables);
HistoricProcessInstance his = historyService.createHistoricProcessInstanceQuery()
.processInstanceId(procInsId)
.singleResult();
if (his != null) {
vo.setStartTime(his.getStartTime());
vo.setEndTime(his.getEndTime());
}
Task currentTask = taskService.createTaskQuery().processInstanceId(procInsId).active().singleResult();
if (currentTask != null) {
vo.setCurrentTaskId(currentTask.getId());
vo.setCurrentTaskKey(currentTask.getTaskDefinitionKey());
vo.setCurrentTaskName(currentTask.getName());
vo.setCurrentAssignee(currentTask.getAssignee());
}
vo.setSteps(buildSteps(procInsId));
return vo;
}
private void applyVariables(OrderDisputeFlowVo vo, Map<String, Object> variables) {
Map<String, Object> safe = variables != null ? variables : Collections.emptyMap();
vo.setVariables(safe);
vo.setOrderCode(MapUtil.getStr(safe, "orderCode"));
vo.setCustomerName(MapUtil.getStr(safe, "customerName"));
}
private List<OrderDisputeFlowStepVo> buildSteps(String procInsId) {
List<Comment> comments = taskService.getProcessInstanceComments(procInsId);
Map<String, String> lastCommentByTaskId = new HashMap<>();
if (CollUtil.isNotEmpty(comments)) {
for (Comment c : comments) {
if (StringUtils.isNotBlank(c.getTaskId())) {
lastCommentByTaskId.put(c.getTaskId(), c.getFullMessage());
}
}
}
HistoricTaskInstanceQuery taskQuery = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(procInsId)
.orderByHistoricTaskInstanceStartTime()
.asc();
List<HistoricTaskInstance> taskInstances = taskQuery.list();
if (CollUtil.isEmpty(taskInstances)) {
return Collections.emptyList();
}
List<OrderDisputeFlowStepVo> stepVos = new ArrayList<>();
for (HistoricTaskInstance ti : taskInstances) {
OrderDisputeFlowStepVo stepVo = new OrderDisputeFlowStepVo();
stepVo.setStepKey(ti.getTaskDefinitionKey());
stepVo.setStepName(ti.getName());
stepVo.setAssignee(ti.getAssignee());
stepVo.setStartTime(ti.getStartTime());
stepVo.setEndTime(ti.getEndTime());
stepVo.setComment(lastCommentByTaskId.get(ti.getId()));
stepVos.add(stepVo);
}
return stepVos;
}
}

View File

@@ -0,0 +1,47 @@
package com.gear.oa.workflow;
import lombok.RequiredArgsConstructor;
import org.flowable.engine.RepositoryService;
import org.flowable.engine.repository.Deployment;
import org.flowable.engine.repository.ProcessDefinition;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@RequiredArgsConstructor
@Component
public class OrderDisputeProcessDeployer implements ApplicationRunner {
public static final String PROCESS_KEY = "oa_order_dispute";
private static final String BPMN_RESOURCE = "processes/oa_order_dispute.bpmn20.xml";
private final RepositoryService repositoryService;
@Override
public void run(ApplicationArguments args) throws Exception {
ProcessDefinition existing = repositoryService.createProcessDefinitionQuery()
.processDefinitionKey(PROCESS_KEY)
.latestVersion()
.singleResult();
if (existing != null) {
return;
}
ClassPathResource resource = new ClassPathResource(BPMN_RESOURCE);
try (InputStream inputStream = resource.getInputStream()) {
Deployment deployment = repositoryService.createDeployment()
.name("订单异议处理")
.key(PROCESS_KEY)
.addInputStream(BPMN_RESOURCE, inputStream)
.deploy();
repositoryService.createProcessDefinitionQuery()
.deploymentId(deployment.getId())
.latestVersion()
.singleResult();
}
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:flowable="http://flowable.org/bpmn"
targetNamespace="http://gear.com/oa/dispute">
<process id="oa_order_dispute" name="订单异议处理" isExecutable="true">
<startEvent id="startEvent" />
<sequenceFlow id="flow_start_accept" sourceRef="startEvent" targetRef="acceptTask" />
<userTask id="acceptTask" name="异议接收" flowable:assignee="${acceptUserId}" />
<sequenceFlow id="flow_accept_physical" sourceRef="acceptTask" targetRef="physicalTask" />
<userTask id="physicalTask" name="实物确认" flowable:assignee="${physicalUserId}" />
<sequenceFlow id="flow_physical_analysis" sourceRef="physicalTask" targetRef="analysisTask" />
<userTask id="analysisTask" name="原因分析" flowable:assignee="${analysisUserId}" />
<sequenceFlow id="flow_analysis_plan" sourceRef="analysisTask" targetRef="planTask" />
<userTask id="planTask" name="处置方案" flowable:assignee="${planUserId}" />
<sequenceFlow id="flow_plan_visit" sourceRef="planTask" targetRef="visitTask" />
<userTask id="visitTask" name="闭环回访" flowable:assignee="${visitUserId}" />
<sequenceFlow id="flow_visit_end" sourceRef="visitTask" targetRef="endEvent" />
<endEvent id="endEvent" />
</process>
</definitions>

View File

@@ -0,0 +1,40 @@
import request from '@/utils/request'
export function getOrderDisputeFlowByOrder(orderId) {
return request({
url: `/oa/orderDisputeFlow/byOrder/${orderId}`,
method: 'get'
})
}
export function startOrderDisputeFlow(data) {
return request({
url: '/oa/orderDisputeFlow/start',
method: 'post',
data
})
}
export function confirmOrderDisputeFlow(data) {
return request({
url: '/oa/orderDisputeFlow/confirm',
method: 'post',
data
})
}
export function listOrderDisputeTodo(stepKey) {
return request({
url: '/oa/orderDisputeFlow/todoList',
method: 'get',
params: { stepKey }
})
}
export function listOrderDisputeTaskList(stepKey, status, limit) {
return request({
url: '/oa/orderDisputeFlow/taskList',
method: 'get',
params: { stepKey, status, limit }
})
}

View File

@@ -71,13 +71,18 @@ const loadData = async () => {
type: 'user'
}))
// 如果只选择用户,过滤掉部门节点
let allNodes = props.userOnly
? transformedUserList
: [...transformedDeptList, ...transformedUserList]
if (props.userOnly) {
// 仅选择用户时直接平铺用户节点,避免因父部门节点被过滤导致整棵树为空
treeData.value = transformedUserList.map(user => ({
...user,
parentId: null,
children: undefined
}))
return
}
// 构建树形结构
treeData.value = buildTree(allNodes)
// 构建部门 + 用户树形结构
treeData.value = buildTree([...transformedDeptList, ...transformedUserList])
} catch (error) {
console.error('加载数据失败:', error)
}

View File

@@ -100,7 +100,7 @@
</el-tabs>
<div class="right-body" v-loading="detailLoading">
<el-empty v-if="entityType === 'customer' ? !selectedCustomerId : !selectedSupplierId" :description="entityType === 'customer' ? '请选择左侧客户' : '请选择左侧供货商'" />
<el-empty v-if="activeTab !== 'edit' && (entityType === 'customer' ? !selectedCustomerId : !selectedSupplierId)" :description="entityType === 'customer' ? '请选择左侧客户' : '请选择左侧供货商'" />
<template v-else-if="entityType === 'customer'">
<div v-show="activeTab === 'detail'">
@@ -305,7 +305,13 @@
</el-table>
<el-dialog :title="disputeDialogTitle" v-model="disputeOpen" width="1100px" append-to-body>
<ReturnExchange v-if="disputeOpen && disputeOrderId" :orderId="disputeOrderId" />
<DisputeFlow
v-if="disputeOpen && disputeOrderId"
:order-id="disputeOrderId"
:order-code="disputeOrderCode"
:customer-id="selectedCustomerId"
:customer-name="selectedCustomer && selectedCustomer.name ? selectedCustomer.name : ''"
/>
<template #footer>
<el-button @click="disputeOpen = false">关闭</el-button>
</template>
@@ -385,12 +391,12 @@ import { listShippingOrder } from "@/api/oms/shippingOrder";
import { listOrderDetail } from "@/api/oms/orderDetail";
import { listReceivable } from "@/api/finance/receivable";
import request from "@/utils/request";
import ReturnExchange from "@/views/oms/order/panels/return.vue";
import DisputeFlow from "@/views/oms/order/panels/disputeFlow.vue";
import * as XLSX from "xlsx";
export default {
name: "Customer",
components: { ReturnExchange },
components: { DisputeFlow },
setup() {
const { proxy } = getCurrentInstance();
const { customer_from } = proxy.useDict("customer_from");

View File

@@ -0,0 +1,473 @@
<template>
<div class="app-container dispute-page">
<div class="dispute-topbar">
<el-tabs v-model="activeTab" class="dispute-topbar__tabs" @tab-click="handleTabChange">
<el-tab-pane v-for="item in stepOptions" :key="item.tab" :name="item.tab">
<template #label>
<span>{{ item.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div class="dispute-topbar__right">
<el-radio-group v-model="activeStatus" size="small" @change="handleStatusChange">
<el-radio-button label="todo">待办</el-radio-button>
<el-radio-button label="done">已处理</el-radio-button>
<el-radio-button label="all">全部</el-radio-button>
</el-radio-group>
<el-input
v-model="keyword"
clearable
placeholder="订单号 / 客户"
@keyup.enter="handleQuery"
@clear="handleQuery"
>
<template #append>
<el-button icon="Search" @click="handleQuery" />
</template>
</el-input>
<el-button plain icon="Refresh" :loading="loading" @click="getList">刷新</el-button>
</div>
</div>
<el-row :gutter="16">
<el-col :xs="24" :sm="8" :md="7" :lg="6" :xl="5" class="dispute-left">
<div class="left-list" v-loading="loading">
<div class="left-header">
<div class="left-header__title">{{ leftTitle }}</div>
<el-button size="small" plain icon="Refresh" :loading="loading" @click="getList">刷新</el-button>
</div>
<el-empty v-if="!displayList.length && !loading" :description="emptyDescription" />
<el-scrollbar v-else class="left-scroll">
<div
v-for="item in displayList"
:key="item.taskId || item.orderId"
class="dispute-card"
:class="{ active: item.orderId === selectedOrderId }"
@click="handleSelectRow(item)"
>
<div class="dispute-card__row">
<div class="dispute-card__title">{{ item.orderCode || '-' }}</div>
<div class="dispute-card__tags">
<el-tag v-if="item.status" size="small" :type="item.status === 'DONE' ? 'success' : 'warning'" effect="plain">
{{ item.status === 'DONE' ? '已处理' : '待办' }}
</el-tag>
<el-tag size="small" type="info" effect="plain">{{ item.taskName || '-' }}</el-tag>
</div>
</div>
<div class="dispute-card__meta">
<div class="dispute-card__meta-item">客户{{ item.customerName || '-' }}</div>
<div class="dispute-card__meta-item">
<span>创建{{ formatTime(item.createTime) }}</span>
<span v-if="item.endTime" style="margin-left: 10px;">完成{{ formatTime(item.endTime) }}</span>
</div>
</div>
</div>
</el-scrollbar>
</div>
</el-col>
<el-col :xs="24" :sm="16" :md="17" :lg="18" :xl="19" class="dispute-right">
<el-card shadow="never" class="right-card">
<template #header>
<div class="right-header">
<div>
<div class="right-title">{{ rightTitle }}</div>
<div class="right-subtitle">
<span v-if="currentRow && currentRow.orderCode">当前{{ currentRow.orderCode }}</span>
<span v-else>请选择左侧异议单据</span>
</div>
</div>
<div class="right-actions">
<el-button plain icon="Refresh" size="small" :loading="loading" @click="getList">刷新</el-button>
</div>
</div>
</template>
<div class="right-body">
<el-empty v-if="!currentRow || !currentRow.orderId" description="← 请选择左侧异议列表" />
<DisputeFlow
v-else
:order-id="currentRow.orderId"
:order-code="currentRow.orderCode"
:customer-id="currentRow.customerId"
:customer-name="currentRow.customerName"
/>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { listOrderDisputeTaskList } from '@/api/oa/orderDisputeFlow'
import DisputeFlow from '@/views/oms/order/panels/disputeFlow.vue'
const stepOptions = [
{ tab: 'all', stepKey: undefined, label: '全部' },
{ tab: 'acceptTask', stepKey: 'acceptTask', label: '异议接收' },
{ tab: 'physicalTask', stepKey: 'physicalTask', label: '实物确认' },
{ tab: 'analysisTask', stepKey: 'analysisTask', label: '原因分析' },
{ tab: 'planTask', stepKey: 'planTask', label: '处置方案' },
{ tab: 'visitTask', stepKey: 'visitTask', label: '闭环回访' }
]
const activeTab = ref('all')
const activeStatus = ref('todo')
const loading = ref(false)
const list = ref([])
const currentRow = ref(null)
const keyword = ref('')
const selectedOrderIdByTab = ref({})
const silentRefreshing = ref(false)
let fastRefreshTimers = []
let autoRefreshTimer = null
const inFlight = ref(false)
let pendingRefresh = false
let lastFetchKey = ''
const stepMeta = computed(() => stepOptions.find(item => item.tab === activeTab.value) || stepOptions[0])
const activeLabel = computed(() => stepMeta.value.label || '全部')
const selectedOrderId = computed(() => currentRow.value?.orderId)
const statusLabel = computed(() => {
if (activeStatus.value === 'done') return '已处理'
if (activeStatus.value === 'all') return '全部'
return '待办'
})
const displayList = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return list.value
return list.value.filter(item => {
const orderCode = (item.orderCode || '').toLowerCase()
const customerName = (item.customerName || '').toLowerCase()
return orderCode.includes(kw) || customerName.includes(kw)
})
})
const emptyDescription = computed(() => {
const prefix = activeLabel.value === '全部' ? '' : activeLabel.value + ' '
return `暂无${prefix}订单异议${statusLabel.value}`.trim()
})
const leftTitle = computed(() => {
const prefix = activeLabel.value === '全部' ? '订单异议' : activeLabel.value
return `${prefix}${statusLabel.value}`
})
const rightTitle = computed(() => {
const prefix = activeLabel.value === '全部' ? '订单异议' : `订单异议 - ${activeLabel.value}`
return `${prefix}${statusLabel.value}`
})
function formatTime(val) {
if (!val) return '-'
try {
return new Date(val).toLocaleString()
} catch (e) {
return String(val)
}
}
function handleQuery() {
const nextList = displayList.value
if (!nextList.length) {
currentRow.value = null
return
}
const selectedId = currentRow.value?.orderId
if (selectedId && nextList.some(item => item.orderId === selectedId)) return
currentRow.value = nextList[0]
selectedOrderIdByTab.value = { ...selectedOrderIdByTab.value, [selectionKey()]: currentRow.value.orderId }
}
function fetchKey() {
return `${activeTab.value}|${activeStatus.value}`
}
async function getList(options) {
lastFetchKey = fetchKey()
if (inFlight.value) {
pendingRefresh = true
return
}
const silent = options && options.silent
if (silent) {
if (silentRefreshing.value) return
silentRefreshing.value = true
} else {
loading.value = true
}
inFlight.value = true
const localFetchKey = lastFetchKey
try {
const res = await listOrderDisputeTaskList(stepMeta.value.stepKey, activeStatus.value, 200)
if (localFetchKey !== fetchKey()) {
return
}
list.value = res?.data || []
restoreSelection()
} finally {
inFlight.value = false
if (silent) {
silentRefreshing.value = false
} else {
loading.value = false
}
if (pendingRefresh) {
pendingRefresh = false
getList({ silent: true })
}
}
}
function handleSelectRow(row) {
currentRow.value = row
selectedOrderIdByTab.value = { ...selectedOrderIdByTab.value, [selectionKey()]: row.orderId }
}
function selectionKey() {
return `${activeTab.value}:${activeStatus.value}`
}
function restoreSelection() {
const savedOrderId = selectedOrderIdByTab.value?.[selectionKey()]
if (!list.value.length) {
currentRow.value = null
return
}
if (savedOrderId) {
const exist = list.value.find(item => item.orderId === savedOrderId)
if (exist) {
currentRow.value = exist
return
}
}
const nextList = displayList.value
currentRow.value = nextList[0] || list.value[0]
if (currentRow.value?.orderId) {
selectedOrderIdByTab.value = { ...selectedOrderIdByTab.value, [selectionKey()]: currentRow.value.orderId }
}
}
function handleTabChange() {
keyword.value = ''
getList()
}
function handleStatusChange() {
keyword.value = ''
getList()
startAutoRefresh()
}
function handleTaskChanged() {
scheduleFastRefresh()
}
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('order-dispute-task-changed', handleTaskChanged)
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
getList()
startAutoRefresh()
})
onActivated(() => {
getList({ silent: true })
startAutoRefresh()
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('order-dispute-task-changed', handleTaskChanged)
}
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
stopAutoRefresh()
})
watch(keyword, () => {
handleQuery()
})
function clearFastRefresh() {
if (fastRefreshTimers && fastRefreshTimers.length) {
fastRefreshTimers.forEach(id => clearTimeout(id))
}
fastRefreshTimers = []
}
function scheduleFastRefresh() {
clearFastRefresh()
getList({ silent: true })
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
fastRefreshTimers.push(setTimeout(() => getList({ silent: true }), 400))
fastRefreshTimers.push(setTimeout(() => getList({ silent: true }), 1200))
fastRefreshTimers.push(setTimeout(() => getList({ silent: true }), 2500))
}
function stopAutoRefresh() {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
}
function startAutoRefresh() {
stopAutoRefresh()
if (activeStatus.value !== 'todo') return
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
autoRefreshTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
getList({ silent: true })
}, 3000)
}
function handleVisibilityChange() {
startAutoRefresh()
}
</script>
<style scoped>
.dispute-page {
height: calc(100vh - 110px);
}
.dispute-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.dispute-topbar__tabs {
flex: 1;
min-width: 360px;
}
.dispute-topbar__tabs :deep(.el-tabs__header) {
margin: 0;
}
.dispute-topbar__right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dispute-left,
.dispute-right {
height: calc(100vh - 170px);
}
.left-list {
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 10px;
background: #fff;
height: 100%;
}
.left-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.left-header__title {
font-weight: 600;
color: #303133;
}
.left-scroll {
height: 100%;
}
.dispute-card {
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
background: #fff;
}
.dispute-card.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.dispute-card__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.dispute-card__tags {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.dispute-card__title {
font-weight: 600;
color: #303133;
line-height: 20px;
word-break: break-all;
}
.dispute-card__meta {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
color: #606266;
font-size: 12px;
}
.right-card {
height: 100%;
}
.right-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.right-title {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.right-subtitle {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
.right-body {
height: calc(100% - 8px);
overflow: auto;
}
</style>

View File

@@ -0,0 +1,432 @@
<template>
<div class="dispute-flow">
<div class="dispute-flow__hero">
<div class="dispute-flow__header">
<div class="dispute-flow__title">
<div class="dispute-flow__title-main">订单异议流程</div>
<div class="dispute-flow__title-sub">
<span v-if="orderCode">订单{{ orderCode }}</span>
<span v-if="flowInfo && flowInfo.procInsId" style="margin-left: 10px;">流程{{ flowInfo.procInsId }}</span>
</div>
</div>
<div class="dispute-flow__actions">
<el-button size="small" plain icon="Refresh" :loading="loading" @click="loadFlow">刷新</el-button>
</div>
</div>
<div class="dispute-flow__summary">
<div class="dispute-flow__summary-item">
<span class="dispute-flow__summary-label">客户</span>
<span class="dispute-flow__summary-value">{{ customerName || '-' }}</span>
</div>
<div class="dispute-flow__summary-item">
<span class="dispute-flow__summary-label">当前状态</span>
<span class="dispute-flow__summary-value">
<el-tag :type="flowLoaded && flowInfo && flowInfo.status === 'FINISHED' ? 'success' : 'warning'" effect="plain">
{{ !flowLoaded ? '加载中' : flowInfo && flowInfo.status === 'FINISHED' ? '已闭环' : flowInfo ? '处理中' : '未发起' }}
</el-tag>
</span>
</div>
<div class="dispute-flow__summary-item">
<span class="dispute-flow__summary-label">当前负责人</span>
<span class="dispute-flow__summary-value">{{ !flowLoaded ? '—' : flowInfo ? userLabel(flowInfo.currentAssignee) : '-' }}</span>
</div>
</div>
</div>
<el-alert
v-if="flowLoaded && !flowInfo"
type="info"
:closable="false"
title="该订单尚未发起异议流程"
show-icon
style="margin-bottom: 12px;"
/>
<div v-if="flowLoaded && !flowInfo && showStart" class="dispute-flow__start">
<div class="dispute-flow__start-card">
<div class="dispute-flow__section-title">指定各节点负责人</div>
<el-form :model="startForm" label-width="90px" class="dispute-flow__start-form">
<el-form-item label="异议接收">
<UserSelect v-model="startForm.acceptUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="实物确认">
<UserSelect v-model="startForm.physicalUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="原因分析">
<UserSelect v-model="startForm.analysisUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="处置方案">
<UserSelect v-model="startForm.planUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="闭环回访">
<UserSelect v-model="startForm.visitUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
</el-form>
<div class="dispute-flow__start-actions">
<el-button type="primary" :loading="startLoading" @click="startFlow">发起流程</el-button>
</div>
</div>
</div>
<template v-else-if="flowLoaded">
<div class="dispute-flow__content">
<div class="dispute-flow__timeline-card">
<div class="dispute-flow__section-title">流程进度</div>
<el-steps direction="vertical" :active="activeStepIndex" finish-status="success">
<el-step
v-for="s in stepDefs"
:key="s.key"
:title="s.label"
:description="stepDesc(s)"
/>
</el-steps>
</div>
<div class="dispute-flow__action-card">
<div class="dispute-flow__section-title">当前操作</div>
<div v-if="flowInfo.status === 'FINISHED'" class="dispute-flow__finished">
<el-alert type="success" :closable="false" title="异议流程已闭环" show-icon />
</div>
<div v-else class="dispute-flow__handle">
<el-form label-width="90px">
<el-form-item label="当前节点">
<el-tag type="warning" v-if="flowInfo.currentTaskName">{{ flowInfo.currentTaskName }}</el-tag>
<span v-else>-</span>
</el-form-item>
<el-form-item label="负责人">
<span>{{ userLabel(flowInfo.currentAssignee) }}</span>
</el-form-item>
<el-form-item label="处理意见">
<el-input v-model="confirmComment" type="textarea" :rows="4" placeholder="填写处理意见(可选)" />
</el-form-item>
</el-form>
<div class="dispute-flow__handle-actions">
<el-button type="primary" :loading="confirmLoading" :disabled="!canConfirm" @click="confirmStep">确认</el-button>
<el-button plain :disabled="confirmLoading" @click="confirmComment = ''">清空</el-button>
</div>
<div v-if="!canConfirm" class="dispute-flow__tip">仅当前节点负责人可执行确认操作</div>
</div>
</div>
</div>
</template>
<template v-else>
<el-skeleton :rows="8" animated />
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { getOrderDisputeFlowByOrder, startOrderDisputeFlow, confirmOrderDisputeFlow } from '@/api/oa/orderDisputeFlow'
import { listUser } from '@/api/system/user'
import UserSelect from '@/components/UserSelect/index.vue'
import useUserStore from '@/store/modules/user'
const props = defineProps({
orderId: {
type: [String, Number],
required: true
},
orderCode: {
type: String,
default: ''
},
customerId: {
type: [String, Number],
default: undefined
},
customerName: {
type: String,
default: ''
},
showStart: {
type: Boolean,
default: true
}
})
const userStore = useUserStore()
const loading = ref(false)
const startLoading = ref(false)
const confirmLoading = ref(false)
const flowInfo = ref(null)
const flowLoaded = ref(false)
const confirmComment = ref('')
const userMap = ref(new Map())
async function loadUsers() {
try {
const res = await listUser({ pageNum: 1, pageSize: 9999 })
const rows = res?.rows || []
const map = new Map()
rows.forEach(u => {
map.set(String(u.userId), u.nickName || u.userName || String(u.userId))
})
userMap.value = map
} catch (e) {
userMap.value = new Map()
}
}
function userLabel(userId) {
if (!userId) return '-'
const key = String(userId)
return userMap.value.get(key) || key
}
const stepDefs = [
{ key: 'acceptTask', label: '异议接收', varKey: 'acceptUserId' },
{ key: 'physicalTask', label: '实物确认', varKey: 'physicalUserId' },
{ key: 'analysisTask', label: '原因分析', varKey: 'analysisUserId' },
{ key: 'planTask', label: '处置方案', varKey: 'planUserId' },
{ key: 'visitTask', label: '闭环回访', varKey: 'visitUserId' }
]
const startForm = reactive({
acceptUserId: undefined,
physicalUserId: undefined,
analysisUserId: undefined,
planUserId: undefined,
visitUserId: undefined
})
function notifyTaskChanged(action) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('order-dispute-task-changed', {
detail: {
action,
orderId: props.orderId != null ? Number(props.orderId) : undefined,
orderCode: props.orderCode || undefined
}
}))
}
function resetStartForm() {
startForm.acceptUserId = userStore.id || undefined
startForm.physicalUserId = undefined
startForm.analysisUserId = undefined
startForm.planUserId = undefined
startForm.visitUserId = undefined
}
async function loadFlow() {
if (!props.orderId) {
flowInfo.value = null
flowLoaded.value = true
return
}
loading.value = true
flowLoaded.value = false
try {
const res = await getOrderDisputeFlowByOrder(props.orderId)
flowInfo.value = res?.data || null
} finally {
loading.value = false
flowLoaded.value = true
}
}
async function startFlow() {
if (!props.orderId) return
startLoading.value = true
try {
await startOrderDisputeFlow({
orderId: String(props.orderId),
orderCode: props.orderCode || undefined,
customerId: props.customerId != null ? String(props.customerId) : undefined,
customerName: props.customerName || undefined,
acceptUserId: startForm.acceptUserId != null ? String(startForm.acceptUserId) : undefined,
physicalUserId: startForm.physicalUserId != null ? String(startForm.physicalUserId) : undefined,
analysisUserId: startForm.analysisUserId != null ? String(startForm.analysisUserId) : undefined,
planUserId: startForm.planUserId != null ? String(startForm.planUserId) : undefined,
visitUserId: startForm.visitUserId != null ? String(startForm.visitUserId) : undefined
})
await loadFlow()
notifyTaskChanged('start')
} finally {
startLoading.value = false
}
}
const currentUserIdStr = computed(() => {
const id = userStore.id
return id != null ? String(id) : ''
})
const canConfirm = computed(() => {
if (!flowInfo.value || flowInfo.value.status !== 'RUNNING') return false
const assignee = flowInfo.value.currentAssignee
return assignee && String(assignee) === currentUserIdStr.value
})
async function confirmStep() {
if (!props.orderId) return
confirmLoading.value = true
try {
await confirmOrderDisputeFlow({
orderId: String(props.orderId),
comment: confirmComment.value || ''
})
confirmComment.value = ''
await loadFlow()
notifyTaskChanged('confirm')
} finally {
confirmLoading.value = false
}
}
function stepDesc(stepDef) {
const fi = flowInfo.value
if (!fi) return ''
const done = (fi.steps || []).find(s => s.stepKey === stepDef.key)
if (done && done.endTime) {
const who = userLabel(done.assignee)
const when = done.endTime ? new Date(done.endTime).toLocaleString() : ''
const msg = done.comment ? `;意见:${done.comment}` : ''
return `已完成:${who}${when ? ';时间:' + when : ''}${msg}`
}
const currentKey = fi.currentTaskKey
if (currentKey === stepDef.key) {
const who = userLabel(fi.currentAssignee)
return `进行中:${who}`
}
const who = userLabel(fi.variables ? fi.variables[stepDef.varKey] : '')
return who && who !== '-' ? `待处理:${who}` : '待处理'
}
const activeStepIndex = computed(() => {
const fi = flowInfo.value
if (!fi) return 0
const idx = stepDefs.findIndex(s => s.key === fi.currentTaskKey)
if (idx >= 0) return idx
const doneCount = (fi.steps || []).filter(s => s.endTime).length
return Math.min(doneCount, stepDefs.length - 1)
})
onMounted(async () => {
resetStartForm()
await loadUsers()
await loadFlow()
})
watch(() => props.orderId, async () => {
resetStartForm()
flowLoaded.value = false
await loadFlow()
})
</script>
<style scoped>
.dispute-flow {
color: #303133;
}
.dispute-flow__hero {
padding: 16px 18px;
border: 1px solid #ebeef5;
border-radius: 12px;
background: linear-gradient(180deg, #ffffff 0%, #fafbfd 100%);
margin-bottom: 16px;
}
.dispute-flow__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dispute-flow__title-main {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.dispute-flow__title-sub {
color: #909399;
margin-top: 4px;
font-size: 12px;
}
.dispute-flow__summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.dispute-flow__summary-item {
padding: 12px 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
}
.dispute-flow__summary-label {
display: block;
color: #909399;
font-size: 12px;
}
.dispute-flow__summary-value {
display: block;
margin-top: 8px;
font-size: 15px;
font-weight: 600;
}
.dispute-flow__section-title {
margin-bottom: 16px;
font-size: 15px;
font-weight: 600;
}
.dispute-flow__start-card,
.dispute-flow__timeline-card,
.dispute-flow__action-card {
padding: 18px;
border: 1px solid #ebeef5;
border-radius: 12px;
background: #fff;
}
.dispute-flow__start-form {
max-width: 520px;
}
.dispute-flow__start-actions {
margin-top: 4px;
}
.dispute-flow__content {
display: grid;
grid-template-columns: minmax(320px, 1.2fr) minmax(320px, 1fr);
gap: 16px;
}
.dispute-flow__handle {
margin-top: 10px;
}
.dispute-flow__handle-actions {
display: flex;
gap: 10px;
}
.dispute-flow__tip {
margin-top: 12px;
color: #909399;
font-size: 12px;
}
@media (max-width: 992px) {
.dispute-flow__summary,
.dispute-flow__content {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -108,6 +108,12 @@
<el-table-column label="原因" prop="reason" min-width="160" />
<el-table-column label="状态" prop="status" width="120" />
<el-table-column label="涉及金额" prop="amount" width="120" />
<el-table-column label="流程" width="160" align="center">
<template #default="scope">
<el-button link type="primary" @click="openStart(scope.row)">发起流程</el-button>
<el-button link type="primary" @click="openFlow(scope.row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<pagination
@@ -118,15 +124,63 @@
@pagination="fetchDetail"
/>
</el-card>
<el-dialog v-model="startOpen" title="发起异议流程" width="720px" append-to-body destroy-on-close>
<el-form :model="startForm" label-width="90px">
<el-form-item label="订单ID">
<el-input :model-value="startRow && startRow.orderId ? String(startRow.orderId) : '-'" disabled />
</el-form-item>
<el-form-item label="客户">
<el-input :model-value="startRow && startRow.customerName ? startRow.customerName : '-'" disabled />
</el-form-item>
<el-form-item label="异议接收">
<UserSelect v-model="startForm.acceptUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="实物确认">
<UserSelect v-model="startForm.physicalUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="原因分析">
<UserSelect v-model="startForm.analysisUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="处置方案">
<UserSelect v-model="startForm.planUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
<el-form-item label="闭环回访">
<UserSelect v-model="startForm.visitUserId" user-only clearable placeholder="选择负责人" style="width: 320px;" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="startOpen = false">取消</el-button>
<el-button type="primary" :loading="startLoading" @click="submitStart">确定发起</el-button>
</template>
</el-dialog>
<el-dialog v-model="flowOpen" title="订单异议流程" width="1200px" append-to-body destroy-on-close>
<el-empty v-if="!flowRow || !flowRow.orderId" description="请选择订单" />
<DisputeFlow
v-else
:order-id="flowRow.orderId"
:customer-id="flowRow.customerId"
:customer-name="flowRow.customerName"
:show-start="false"
/>
<template #footer>
<el-button @click="flowOpen = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="ReturnExchangeSummary">
import { computed, getCurrentInstance, onMounted, reactive, ref, toRefs } from 'vue'
import CustomerSelect from '@/components/CustomerSelect/index.vue'
import UserSelect from '@/components/UserSelect/index.vue'
import DisputeFlow from '@/views/oms/order/panels/disputeFlow.vue'
import { listSalesman } from '@/api/oms/salesman'
import { getReturnExchangeSummary, listReturnExchange } from '@/api/oa/returnExchange'
import { startOrderDisputeFlow } from '@/api/oa/orderDisputeFlow'
import * as XLSX from 'xlsx'
import useUserStore from '@/store/modules/user'
const { proxy } = getCurrentInstance()
@@ -138,6 +192,73 @@ const salesmanOptions = ref([])
const timeRange = ref([])
const summaryDims = ref([])
const exportGroupBy = ref('')
const startOpen = ref(false)
const startLoading = ref(false)
const startRow = ref(null)
const flowOpen = ref(false)
const flowRow = ref(null)
const userStore = useUserStore()
const startForm = reactive({
acceptUserId: undefined,
physicalUserId: undefined,
analysisUserId: undefined,
planUserId: undefined,
visitUserId: undefined
})
function resetStartForm() {
const uid = userStore.id
startForm.acceptUserId = uid != null ? String(uid) : undefined
startForm.physicalUserId = uid != null ? String(uid) : undefined
startForm.analysisUserId = uid != null ? String(uid) : undefined
startForm.planUserId = uid != null ? String(uid) : undefined
startForm.visitUserId = uid != null ? String(uid) : undefined
}
function openStart(row) {
startRow.value = row
resetStartForm()
startOpen.value = true
}
function openFlow(row) {
flowRow.value = row
flowOpen.value = true
}
function notifyTaskChanged(action, orderId) {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('order-dispute-task-changed', {
detail: { action, orderId }
}))
}
async function submitStart() {
if (!startRow.value || !startRow.value.orderId) {
proxy.$modal.msgError('缺少订单ID')
return
}
startLoading.value = true
try {
await startOrderDisputeFlow({
orderId: String(startRow.value.orderId),
customerId: startRow.value.customerId != null ? String(startRow.value.customerId) : undefined,
customerName: startRow.value.customerName || undefined,
acceptUserId: startForm.acceptUserId != null ? String(startForm.acceptUserId) : undefined,
physicalUserId: startForm.physicalUserId != null ? String(startForm.physicalUserId) : undefined,
analysisUserId: startForm.analysisUserId != null ? String(startForm.analysisUserId) : undefined,
planUserId: startForm.planUserId != null ? String(startForm.planUserId) : undefined,
visitUserId: startForm.visitUserId != null ? String(startForm.visitUserId) : undefined
})
proxy.$modal.msgSuccess('已发起流程')
startOpen.value = false
notifyTaskChanged('start', String(startRow.value.orderId))
} finally {
startLoading.value = false
}
}
const summary = reactive({
totalCount: 0,

649
index.html Normal file
View File

@@ -0,0 +1,649 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>投诉管理系统 - 嘉祥科伦普重工有限公司</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:"Microsoft YaHei","SimSun",sans-serif;background:#f0f0f0;color:#000;min-height:100vh;}
.topbar{background:#1a1a1a;color:#fff;height:48px;display:flex;align-items:center;padding:0 20px;position:fixed;top:0;left:0;right:0;z-index:100;}
.topbar .logo{font-size:16px;font-weight:bold;margin-right:40px;letter-spacing:2px;}
.topbar .menu-item{padding:0 16px;height:48px;line-height:48px;cursor:pointer;font-size:13px;transition:background .2s;}
.topbar .menu-item:hover,.topbar .menu-item.active{background:#333;}
.topbar .menu-item.active{border-bottom:3px solid #c00;}
.page-view{display:none;flex-direction:row;height:calc(100vh - 48px);margin-top:48px;}
.page-view.active{display:flex;}
.left-panel{width:300px;min-width:300px;background:#fff;border-right:2px solid #000;display:flex;flex-direction:column;}
.left-header{padding:12px 16px;border-bottom:2px solid #000;display:flex;justify-content:space-between;align-items:center;}
.left-header h3{font-size:15px;}
.btn{padding:6px 16px;border:2px solid #000;background:#fff;color:#000;cursor:pointer;font-size:13px;font-weight:bold;border-radius:2px;transition:all .15s;}
.btn:hover{background:#000;color:#fff;}
.btn-sm{padding:3px 10px;font-size:12px;}
.btn-danger{border-color:#c00;color:#c00;}.btn-danger:hover{background:#c00;color:#fff;}
.btn-primary{background:#1a1a1a;color:#fff;border-color:#1a1a1a;}.btn-primary:hover{background:#333;}
.btn-success{border-color:#090;color:#090;}.btn-success:hover{background:#090;color:#fff;}
.btn-warn{border-color:#c60;color:#c60;}.btn-warn:hover{background:#c60;color:#fff;}
.status-badge{display:inline-block;padding:2px 10px;font-size:11px;font-weight:bold;border:2px solid #000;}
.sb-ur{border-color:#c60;color:#c60;}.sb-rj{border-color:#c00;color:#c00;}.sb-ap{border-color:#090;color:#090;}
.sb-ud{border-color:#c60;color:#c60;}.sb-dn{border-color:#090;color:#090;}
.complaint-list{flex:1;overflow-y:auto;}
.complaint-item{padding:12px 16px;border-bottom:1px solid #ccc;cursor:pointer;transition:background .15s;}
.complaint-item:hover{background:#f5f5f5;}
.complaint-item.active{background:#e8e8e8;border-left:4px solid #000;}
.complaint-item .ci-id{font-weight:bold;font-size:14px;}
.complaint-item .ci-date{font-size:12px;color:#666;margin-top:4px;}
.complaint-item .ci-preview{font-size:12px;color:#333;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.complaint-item .ci-status{margin-top:4px;}
.right-panel{flex:1;overflow-y:auto;background:#fff;padding:24px;}
.right-panel .watermark-top{color:#b0b0b0;font-size:13px;margin-bottom:2px;}
.right-panel .watermark-id{color:#b0b0b0;font-size:12px;margin-bottom:16px;}
.right-panel h2{font-size:18px;border-bottom:2px solid #000;padding-bottom:8px;margin-bottom:20px;}
.empty-state{display:flex;align-items:center;justify-content:center;height:100%;font-size:15px;color:#999;}
.section{margin-bottom:20px;border:2px solid #000;}
.section-title{background:#1a1a1a;color:#fff;padding:8px 14px;font-size:14px;font-weight:bold;letter-spacing:1px;}
.section-body{padding:14px;}
.form-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px 16px;}
.form-grid.cols2{grid-template-columns:1fr 1fr;}
.form-grid.cols4{grid-template-columns:1fr 1fr 1fr 1fr;}
.form-group{display:flex;flex-direction:column;}
.form-group label{font-size:12px;font-weight:bold;color:#333;margin-bottom:3px;}
.form-group label::after{content:'';display:block;width:20px;height:2px;background:#000;margin-top:2px;}
.form-group input,.form-group textarea,.form-group select{border:2px solid #000;padding:6px 8px;font-size:13px;background:#fff;color:#000;font-family:inherit;border-radius:0;outline:none;transition:border-color .15s;}
.form-group input:focus,.form-group textarea:focus{border-color:#444;}
.form-group textarea{resize:vertical;min-height:55px;}
.form-group.full{grid-column:1/-1;}
.sub-table-wrap{margin-top:10px;border:2px solid #000;}
.sub-table-title{background:#ddd;padding:6px 10px;font-size:12px;font-weight:bold;border-bottom:2px solid #000;display:flex;justify-content:space-between;align-items:center;}
.sub-table{width:100%;border-collapse:collapse;font-size:12px;}
.sub-table th{background:#eee;border:1px solid #000;padding:4px;font-size:11px;text-align:center;}
.sub-table td{border:1px solid #000;padding:3px 4px;text-align:center;vertical-align:middle;}
.sub-table td input,.sub-table td textarea{width:100%;border:none;padding:3px;font-size:11px;text-align:center;background:transparent;font-family:inherit;}
.sub-table td input:focus,.sub-table td textarea:focus{outline:1px solid #000;background:#fafafa;}
.sub-table td textarea{resize:vertical;min-height:30px;text-align:left;}
.action-bar{display:flex;gap:10px;margin-top:20px;padding-top:16px;border-top:2px solid #000;flex-wrap:wrap;}
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:200;justify-content:center;align-items:center;}
.modal-overlay.show{display:flex;}
.modal{background:#fff;border:2px solid #000;width:720px;max-height:80vh;display:flex;flex-direction:column;}
.modal-header{padding:12px 16px;border-bottom:2px solid #000;display:flex;justify-content:space-between;align-items:center;background:#1a1a1a;color:#fff;}
.modal-header h4{font-size:14px;}
.modal-close{cursor:pointer;font-size:18px;font-weight:bold;background:none;border:none;color:#fff;}
.modal-body{flex:1;overflow-y:auto;padding:16px;}
.modal-search{display:flex;gap:8px;margin-bottom:12px;}
.modal-search input{flex:1;border:2px solid #000;padding:6px 10px;font-size:13px;}
.modal-table{width:100%;border-collapse:collapse;font-size:12px;}
.modal-table th{background:#eee;border:1px solid #000;padding:6px 8px;text-align:left;}
.modal-table td{border:1px solid #000;padding:6px 8px;}
.modal-table tr:hover td{background:#f5f5f5;cursor:pointer;}
.modal-table tr.selected td{background:#d9d9d9;font-weight:bold;}
.modal-footer{padding:12px 16px;border-top:2px solid #000;display:flex;justify-content:flex-end;gap:8px;}
.tag{display:inline-block;padding:2px 8px;border:1px solid #000;font-size:11px;margin:2px 4px 2px 0;background:#f5f5f5;}
.tag .tag-remove{margin-left:6px;cursor:pointer;font-weight:bold;color:#c00;}
.file-upload-area{border:2px dashed #999;padding:18px;text-align:center;color:#999;font-size:13px;cursor:pointer;margin-top:10px;}
.file-upload-area:hover{border-color:#000;color:#000;}
.file-list{margin-top:8px;}
.file-item{display:flex;justify-content:space-between;align-items:center;padding:4px 8px;border:1px solid #ccc;margin-bottom:4px;font-size:12px;}
.file-item .fi-name{flex:1;}
.file-item .fi-remove{cursor:pointer;color:#c00;font-weight:bold;margin-left:8px;}
.info-item{margin-bottom:8px;font-size:13px;}
.info-item .ii-label{font-weight:bold;display:inline-block;width:80px;}
.info-row{font-size:13px;line-height:1.8;}
/* 数据分析样式 */
.stats-page{padding:20px 28px;overflow-y:auto;height:100%;}
.stats-page h2{font-size:20px;border-bottom:2px solid #000;padding-bottom:8px;margin-bottom:20px;}
.stats-page h3{font-size:15px;border-left:4px solid #000;padding-left:10px;margin:20px 0 14px;}
.filter-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:20px;padding:12px 16px;border:2px solid #000;background:#fafafa;}
.filter-bar label{font-size:13px;font-weight:bold;}
.filter-bar input[type=date]{border:2px solid #000;padding:5px 8px;font-size:13px;}
.card-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;}
.card-row.col5{grid-template-columns:repeat(5,1fr);}
.stat-card{border:2px solid #000;padding:14px;text-align:center;background:#fff;}
.stat-card .sc-num{font-size:26px;font-weight:bold;margin-bottom:2px;}
.stat-card .sc-label{font-size:11px;color:#555;}
.sc-red{color:#c00;}.sc-green{color:#090;}.sc-orange{color:#c60;}
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;}
.chart-row.col1{grid-template-columns:1fr;}
.chart-box{border:2px solid #000;padding:12px;background:#fff;}
.chart-box .chart-title{font-size:13px;font-weight:bold;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #ccc;}
.chart-box canvas{max-height:280px;width:100% !important;}
.chart-box.col-full{grid-column:1/-1;}
.chart-box.col-full canvas{max-height:320px;}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">嘉祥科伦普重工有限公司</div>
<div class="menu-item active" data-page="complaint" onclick="switchPage('complaint')">投诉受理</div>
<div class="menu-item" data-page="confirm" onclick="switchPage('confirm')">投诉确认</div>
<div class="menu-item" data-page="analysis" onclick="switchPage('analysis')">投诉分析</div>
<div class="menu-item" data-page="processing" onclick="switchPage('processing')">投诉处理</div>
<div class="menu-item" data-page="followup" onclick="switchPage('followup')">回访确认</div>
<div class="menu-item" data-page="statistics" onclick="switchPage('statistics')">数据分析</div>
</div>
<!-- 投诉受理 -->
<div class="page-view active" id="page-complaint">
<div class="left-panel"><div class="left-header"><h3>投诉受理单</h3><button class="btn" onclick="createNew()">+ 新建</button></div><div class="complaint-list" id="complaint-list"></div></div>
<div class="right-panel"><div class="empty-state" id="comp-empty">← 请选择或新建投诉受理单</div><div id="comp-detail" style="display:none;"></div></div>
</div>
<!-- 投诉确认 -->
<div class="page-view" id="page-confirm">
<div class="left-panel"><div class="left-header"><h3>投诉确认单</h3><button class="btn" onclick="createConfirm()">+ 新建</button></div><div class="complaint-list" id="confirm-list"></div></div>
<div class="right-panel"><div class="empty-state" id="cfm-empty">← 请选择或新建投诉确认单</div><div id="cfm-detail" style="display:none;"></div></div>
</div>
<!-- 投诉分析 -->
<div class="page-view" id="page-analysis">
<div class="left-panel"><div class="left-header"><h3>投诉分析单</h3><button class="btn" onclick="createAnalysis()">+ 新建</button></div><div class="complaint-list" id="analysis-list"></div></div>
<div class="right-panel"><div class="empty-state" id="anl-empty">← 请选择或新建投诉分析单</div><div id="anl-detail" style="display:none;"></div></div>
</div>
<!-- 投诉处理 -->
<div class="page-view" id="page-processing">
<div class="left-panel"><div class="left-header"><h3>投诉处理单</h3><button class="btn" onclick="createProcessing()">+ 新建</button></div><div class="complaint-list" id="processing-list"></div></div>
<div class="right-panel"><div class="empty-state" id="prc-empty">← 请选择或新建投诉处理单</div><div id="prc-detail" style="display:none;"></div></div>
</div>
<!-- 回访确认 -->
<div class="page-view" id="page-followup">
<div class="left-panel"><div class="left-header"><h3>回访确认单</h3><button class="btn" onclick="createFollowup()">+ 新建</button></div><div class="complaint-list" id="followup-list"></div></div>
<div class="right-panel"><div class="empty-state" id="flp-empty">← 请选择或新建回访确认单</div><div id="flp-detail" style="display:none;"></div></div>
</div>
<!-- 数据分析 -->
<div class="page-view" id="page-statistics">
<div class="stats-page" style="flex:1;">
<h2>投诉数据分析</h2>
<div class="filter-bar">
<label>分析时段:</label>
<input type="date" id="stats-start" onchange="refreshStats()">
<span></span>
<input type="date" id="stats-end" onchange="refreshStats()">
<button class="btn btn-sm" onclick="resetStatsDate()">重置</button>
<span style="font-size:11px;color:#999;margin-left:10px;">默认当月1日至今</span>
</div>
<h3>一、基本统计</h3>
<div class="card-row col5" id="stats-basic"></div>
<h3>二、日趋势分析</h3>
<div class="chart-row"><div class="chart-box"><div class="chart-title">投诉量 & 业务员日趋势</div><canvas id="chart-daily-count"></canvas></div><div class="chart-box"><div class="chart-title">投诉金额日趋势</div><canvas id="chart-daily-amount"></canvas></div></div>
<div class="chart-row"><div class="chart-box"><div class="chart-title">各环节单据日新增趋势</div><canvas id="chart-daily-docs"></canvas></div></div>
<h3>三、月均分析近6个月</h3>
<div class="chart-row"><div class="chart-box"><div class="chart-title">月度投诉量 & 业务员数</div><canvas id="chart-monthly-count"></canvas></div><div class="chart-box"><div class="chart-title">月度投诉金额</div><canvas id="chart-monthly-amount"></canvas></div></div>
<div class="chart-row"><div class="chart-box"><div class="chart-title">各环节单据月新增趋势</div><canvas id="chart-monthly-docs"></canvas></div></div>
<h3>四、占比分析</h3>
<div class="card-row" id="stats-proportion-cards"></div>
<div class="chart-row"><div class="chart-box"><div class="chart-title">投诉原因分类占比</div><canvas id="chart-reason-pie"></canvas></div><div class="chart-box"><div class="chart-title">产品类型投诉占比</div><canvas id="chart-product-pie"></canvas></div></div>
<div class="chart-row"><div class="chart-box"><div class="chart-title">各环节单据处理率</div><canvas id="chart-stage-rate"></canvas></div><div class="chart-box"><div class="chart-title">业务员投诉分布</div><canvas id="chart-salesman-bar"></canvas></div></div>
<div class="chart-row"><div class="chart-box col-full"><div class="chart-title">审核通过率</div><canvas id="chart-review-rate"></canvas></div></div>
</div>
</div>
<!-- 弹窗 -->
<div class="modal-overlay" id="modal-contract"><div class="modal"><div class="modal-header"><h4>选择合同</h4><button class="modal-close" onclick="closeModal('modal-contract')">&times;</button></div><div class="modal-body"><div class="modal-search"><input type="text" placeholder="搜索合同..." oninput="filterContracts(this.value)"></div><table class="modal-table" id="contract-table"><thead><tr><th>合同编号</th><th>合同名称</th><th>销售员</th><th>签订时间</th></tr></thead><tbody></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-contract')">取消</button><button class="btn btn-primary" onclick="confirmContract()">确认选择</button></div></div></div>
<div class="modal-overlay" id="modal-coil"><div class="modal"><div class="modal-header"><h4>选择钢卷</h4><button class="modal-close" onclick="closeModal('modal-coil')">&times;</button></div><div class="modal-body"><div class="modal-search"><input type="text" placeholder="搜索卷号/规格/材质..." oninput="filterCoils(this.value)"></div><table class="modal-table" id="coil-table"><thead><tr><th>入场卷号</th><th>规格</th><th>材质</th><th>重量(t)</th><th>厂家</th></tr></thead><tbody></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-coil')">取消</button><button class="btn btn-primary" onclick="confirmCoils()">确认选择</button></div></div></div>
<div class="modal-overlay" id="modal-review"><div class="modal" style="width:480px;"><div class="modal-header"><h4>投诉审核</h4><button class="modal-close" onclick="closeModal('modal-review')">&times;</button></div><div class="modal-body"><div style="margin-bottom:12px;"><label style="font-size:13px;font-weight:bold;">审核结果:</label><select id="review-result" style="border:2px solid #000;padding:6px 10px;font-size:13px;width:100%;margin-top:6px;"><option value="approved">已通过</option><option value="rejected">未通过</option></select></div><div><label style="font-size:13px;font-weight:bold;">审核意见:</label><textarea id="review-note" style="border:2px solid #000;padding:6px 8px;font-size:13px;width:100%;min-height:80px;margin-top:6px;font-family:inherit;"></textarea></div></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-review')">取消</button><button class="btn btn-primary" onclick="doReview()">提交审核</button></div></div></div>
<div class="modal-overlay" id="modal-import-cfm"><div class="modal"><div class="modal-header"><h4>选择已审核通过的投诉受理单</h4><button class="modal-close" onclick="closeModal('modal-import-cfm')">&times;</button></div><div class="modal-body"><table class="modal-table"><thead><tr><th>投诉编号</th><th>投诉日期</th><th>投诉情况</th><th>审核状态</th></tr></thead><tbody id="import-cfm-tbody"></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-import-cfm')">取消</button></div></div></div>
<div class="modal-overlay" id="modal-import-anl"><div class="modal"><div class="modal-header"><h4>选择已处理的投诉确认单</h4><button class="modal-close" onclick="closeModal('modal-import-anl')">&times;</button></div><div class="modal-body"><table class="modal-table"><thead><tr><th>确认单号</th><th>来源受理单</th><th>处理人</th><th>状态</th></tr></thead><tbody id="import-anl-tbody"></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-import-anl')">取消</button></div></div></div>
<div class="modal-overlay" id="modal-import-prc"><div class="modal"><div class="modal-header"><h4>选择已处理的投诉分析单</h4><button class="modal-close" onclick="closeModal('modal-import-prc')">&times;</button></div><div class="modal-body"><table class="modal-table"><thead><tr><th>分析单号</th><th>来源确认单</th><th>处理人</th><th>状态</th></tr></thead><tbody id="import-prc-tbody"></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-import-prc')">取消</button></div></div></div>
<div class="modal-overlay" id="modal-import-flp"><div class="modal"><div class="modal-header"><h4>选择已处理的投诉处理单</h4><button class="modal-close" onclick="closeModal('modal-import-flp')">&times;</button></div><div class="modal-body"><table class="modal-table"><thead><tr><th>处理单号</th><th>来源分析单</th><th>处理人</th><th>状态</th></tr></thead><tbody id="import-flp-tbody"></tbody></table></div><div class="modal-footer"><button class="btn" onclick="closeModal('modal-import-flp')">取消</button></div></div></div>
<script>
/* ===== 模拟数据 ===== */
const mockContracts=[{id:1,name:'热轧钢卷采购合同',no:'HT-2026-001',amount:1280000,salesman:'张伟',signDate:'2026-03-15',deliveryDate:'2026-06-30',signPlace:'山东嘉祥',productName:'热轧钢卷',factory:'宝钢股份',items:[{seq:1,spec:'3.0×1250',material:'Q235B',qty:80,priceTax:5200,rate:1.13,priceNoTax:4601.77,totalTax:416000,totalNoTax:368141.59,taxAmount:47858.41,remark:''},{seq:2,spec:'4.0×1500',material:'Q345B',qty:120,priceTax:5400,rate:1.13,priceNoTax:4778.76,totalTax:648000,totalNoTax:573451.33,taxAmount:74548.67,remark:''}]},{id:2,name:'冷轧板采购合同',no:'HT-2026-002',amount:960000,salesman:'李强',signDate:'2026-04-01',deliveryDate:'2026-07-15',signPlace:'山东济南',productName:'冷轧板',factory:'鞍钢集团',items:[{seq:1,spec:'1.5×1250',material:'DC01',qty:200,priceTax:4800,rate:1.13,priceNoTax:4247.79,totalTax:960000,totalNoTax:849557.52,taxAmount:110442.48,remark:''}]},{id:3,name:'镀锌钢卷采购合同',no:'HT-2026-003',amount:1560000,salesman:'王芳',signDate:'2026-05-10',deliveryDate:'2026-08-20',signPlace:'山东青岛',productName:'镀锌钢卷',factory:'首钢集团',items:[{seq:1,spec:'2.0×1250',material:'DX51D',qty:150,priceTax:5600,rate:1.13,priceNoTax:4955.75,totalTax:840000,totalNoTax:743362.83,taxAmount:96637.17,remark:''},{seq:2,spec:'2.5×1500',material:'DX52D',qty:100,priceTax:7200,rate:1.13,priceNoTax:6371.68,totalTax:720000,totalNoTax:637168.14,taxAmount:82831.86,remark:''}]}];
const mockCoils=[{id:1,entryNo:'RC-2026-001',currentNo:'CC-2026-001',chromeNo:'GC-2026-001',location:'A-3-12',material:'热轧钢卷',spec:'3.0×1250',weight:15.6,texture:'Q235B',factory:'宝钢股份',zone:'A区',createTime:'2026-04-10',quality:'一级',surface:'光面',remark:'',trim:'切边',packaging:'铁皮包装',coatingQuality:'优'},{id:2,entryNo:'RC-2026-002',currentNo:'CC-2026-002',chromeNo:'GC-2026-002',location:'A-3-13',material:'热轧钢卷',spec:'4.0×1500',weight:18.2,texture:'Q345B',factory:'宝钢股份',zone:'A区',createTime:'2026-04-12',quality:'一级',surface:'光面',remark:'',trim:'切边',packaging:'铁皮包装',coatingQuality:'优'},{id:3,entryNo:'RC-2026-003',currentNo:'CC-2026-003',chromeNo:'GC-2026-003',location:'B-5-08',material:'冷轧板',spec:'1.5×1250',weight:12.8,texture:'DC01',factory:'鞍钢集团',zone:'B区',createTime:'2026-04-15',quality:'一级',surface:'光面',remark:'',trim:'不切边',packaging:'塑料膜包装',coatingQuality:'良'},{id:4,entryNo:'RC-2026-004',currentNo:'CC-2026-004',chromeNo:'GC-2026-004',location:'C-7-21',material:'镀锌钢卷',spec:'2.0×1250',weight:20.1,texture:'DX51D',factory:'首钢集团',zone:'C区',createTime:'2026-05-05',quality:'一级',surface:'镀锌',remark:'',trim:'切边',packaging:'铁皮包装',coatingQuality:'优'},{id:5,entryNo:'RC-2026-005',currentNo:'CC-2026-005',chromeNo:'GC-2026-005',location:'C-7-22',material:'镀锌钢卷',spec:'2.5×1500',weight:22.4,texture:'DX52D',factory:'首钢集团',zone:'C区',createTime:'2026-05-08',quality:'一级',surface:'镀锌',remark:'',trim:'切边',packaging:'铁皮包装',coatingQuality:'优'}];
let complaints=[{id:'TS-2026-001',date:'2026-04-05',situation:'热轧钢卷表面出现锈蚀斑点,影响后续加工质量。',demand:'要求退换货或赔偿损失。',remark:'客户已提供照片证据。',contractId:1,coilIds:[1,2],reviewStatus:'approved',reviewNote:'情况属实,同意受理。'},{id:'TS-2026-002',date:'2026-04-08',situation:'冷轧板厚度不均匀,部分区域偏差超过标准范围。',demand:'要求重新检测并补发合格产品。',remark:'已取样送检。',contractId:2,coilIds:[3],reviewStatus:'approved',reviewNote:'经核实,情况属实。'},{id:'TS-2026-003',date:'2026-04-12',situation:'镀锌钢卷镀层厚度不达标,客户要求退货。',demand:'全额退款并承担运费。',remark:'第三方检测报告已出具。',contractId:3,coilIds:[4,5],reviewStatus:'rejected',reviewNote:'证据不足,需补充第三方检测报告原件。'},{id:'TS-2026-004',date:'2026-04-20',situation:'热轧钢卷边部开裂,加工时出现断裂。',demand:'退货处理并补偿加工损失。',remark:'加工厂现场照片已提交。',contractId:1,coilIds:[1],reviewStatus:'approved',reviewNote:'加工断裂属实,同意受理。'},{id:'TS-2026-005',date:'2026-05-05',situation:'冷轧板表面有明显划痕,影响喷涂效果。',demand:'换货或折价处理。',remark:'划痕深度超0.1mm,已拍照。',contractId:2,coilIds:[3],reviewStatus:'approved',reviewNote:'划痕明显,同意受理。'},{id:'TS-2026-006',date:'2026-05-10',situation:'镀锌钢卷锌层附着力不足,客户投诉。',demand:'要求技术检测并赔偿。',remark:'附着力测试不合格报告已出具。',contractId:3,coilIds:[4],reviewStatus:'approved',reviewNote:'附着力不达标,同意受理。'},{id:'TS-2026-007',date:'2026-05-18',situation:'热轧钢卷规格偏差宽度超差3mm。',demand:'按合同约定赔偿。',remark:'客户自检记录已提供。',contractId:1,coilIds:[2],reviewStatus:'approved',reviewNote:'规格偏差属实。'},{id:'TS-2026-008',date:'2026-05-22',situation:'冷轧板包装破损导致运输中进水生锈。',demand:'换货并承担运费。',remark:'物流签收记录显示外包装破损。',contractId:2,coilIds:[3],reviewStatus:'unreviewed',reviewNote:''},{id:'TS-2026-009',date:'2026-06-01',situation:'镀锌钢卷表面有氧化白斑,外观不合格。',demand:'换货处理。',remark:'白斑面积约占20%。',contractId:3,coilIds:[5],reviewStatus:'approved',reviewNote:'白斑明显,同意受理。'},{id:'TS-2026-010',date:'2026-06-05',situation:'热轧钢卷硬度偏高,加工困难。',demand:'退换货。',remark:'硬度检测值超标准12%。',contractId:1,coilIds:[1,2],reviewStatus:'approved',reviewNote:'硬度超标属实。'},{id:'TS-2026-011',date:'2026-06-08',situation:'冷轧板平整度不佳,板型波浪明显。',demand:'折价处理或退货。',remark:'波高约3mm/米。',contractId:2,coilIds:[3],reviewStatus:'rejected',reviewNote:'波高标准在允许范围内,不予受理。'},{id:'TS-2026-012',date:'2026-06-10',situation:'镀锌钢卷运输变形,端部碰撞受损。',demand:'赔偿端部受损部分。',remark:'物流保险已报备。',contractId:3,coilIds:[4,5],reviewStatus:'approved',reviewNote:'运输损坏,保险理赔中。'},{id:'TS-2026-013',date:'2026-06-14',situation:'热轧钢卷氧化皮过厚,酸洗效果差。',demand:'技术指导或换货。',remark:'酸洗线反馈。',contractId:1,coilIds:[1],reviewStatus:'approved',reviewNote:'氧化皮偏厚,同意受理。'},{id:'TS-2026-014',date:'2026-06-16',situation:'冷轧板屈服强度偏低,冲压开裂。',demand:'退货并赔偿模具损失。',remark:'力学性能检测报告已出具。',contractId:2,coilIds:[3],reviewStatus:'unreviewed',reviewNote:''}];
let confirmations=[{id:'QR-2026-001',complaintId:'TS-2026-001',date:'2026-04-07',handler:'张伟',confirmContent:'经现场核实,热轧钢卷表面确有锈蚀,属于运输过程中防护不当所致。已与宝钢股份沟通,同意换货处理。',confirmResult:'换货处理,由供应商承担运费。',status:'done',files:[{name:'现场照片1.jpg',size:'2.3MB'},{name:'检测报告.pdf',size:'1.1MB'}]},{id:'QR-2026-002',complaintId:'TS-2026-002',date:'2026-04-10',handler:'李强',confirmContent:'冷轧板厚度偏差已送第三方检测,结果超出允许偏差范围。',confirmResult:'补发合格产品并扣除该批次货款5%作为违约金。',status:'done',files:[]},{id:'QR-2026-003',complaintId:'TS-2026-004',date:'2026-04-22',handler:'张伟',confirmContent:'热轧钢卷边部开裂确属原材料缺陷。',confirmResult:'退货并补偿加工损失费8000元。',status:'done',files:[{name:'开裂照片.jpg',size:'3.1MB'}]},{id:'QR-2026-004',complaintId:'TS-2026-005',date:'2026-05-07',handler:'李强',confirmContent:'冷轧板表面划痕系开平加工时设备刮擦所致。',confirmResult:'换货处理,加工厂承担运费。',status:'done',files:[]},{id:'QR-2026-005',complaintId:'TS-2026-006',date:'2026-05-12',handler:'王芳',confirmContent:'镀锌钢卷锌层附着力不达标,属涂镀工艺问题。',confirmResult:'退货退款,供应商承担全部费用。',status:'done',files:[{name:'附着力检测报告.pdf',size:'0.8MB'}]},{id:'QR-2026-006',complaintId:'TS-2026-007',date:'2026-05-20',handler:'张伟',confirmContent:'热轧钢卷宽度偏差属实,超出合同约定公差。',confirmResult:'按合同约定赔偿货值3%。',status:'done',files:[]},{id:'QR-2026-007',complaintId:'TS-2026-009',date:'2026-06-03',handler:'王芳',confirmContent:'镀锌钢卷表面氧化白斑属储存环境湿度过高所致。',confirmResult:'换货处理,同时协助客户改善存储条件。',status:'undone',files:[{name:'白斑照片.jpg',size:'1.5MB'}]},{id:'QR-2026-008',complaintId:'TS-2026-010',date:'2026-06-07',handler:'张伟',confirmContent:'热轧钢卷硬度偏高属热处理工艺偏差。',confirmResult:'退货处理,供应商自查热处理工艺。',status:'undone',files:[]},{id:'QR-2026-009',complaintId:'TS-2026-012',date:'2026-06-12',handler:'王芳',confirmContent:'镀锌钢卷运输受损,物流公司已确认责任。',confirmResult:'保险公司理赔中,先行补发。',status:'done',files:[{name:'物流签收单.pdf',size:'0.3MB'}]},{id:'QR-2026-010',complaintId:'TS-2026-013',date:'2026-06-15',handler:'张伟',confirmContent:'热轧钢卷氧化皮偏厚,影响酸洗效率。',confirmResult:'技术团队赴现场指导酸洗工艺调整。',status:'undone',files:[]}];
let analyses=[{id:'FX-2026-001',confirmId:'QR-2026-001',date:'2026-04-09',handler:'张伟',reasons:[{seq:1,reason:'运输途中防锈措施不足,导致钢卷进水',measure:'要求物流公司加强防雨布覆盖,运输签收时拍照留证'},{seq:2,reason:'仓库存储环境湿度过高',measure:'排查仓库除湿设备确保湿度≤60%'}],status:'done'},{id:'FX-2026-002',confirmId:'QR-2026-002',date:'2026-04-14',handler:'李强',reasons:[{seq:1,reason:'轧机辊缝控制精度不足,导致厚度偏差',measure:'建议供应商校准轧机AGC系统'}],status:'done'},{id:'FX-2026-003',confirmId:'QR-2026-003',date:'2026-04-25',handler:'张伟',reasons:[{seq:1,reason:'连铸坯内部存在裂纹缺陷',measure:'加强连铸坯探伤检测,不合格品不入库'}],status:'done'},{id:'FX-2026-004',confirmId:'QR-2026-004',date:'2026-05-10',handler:'李强',reasons:[{seq:1,reason:'开平线输送辊表面有硬质异物',measure:'定期清理输送辊,增加检查频次'}],status:'done'},{id:'FX-2026-005',confirmId:'QR-2026-005',date:'2026-05-15',handler:'王芳',reasons:[{seq:1,reason:'镀锌线锌液温度控制不稳定',measure:'升级温控系统,增加温度监测点'},{seq:2,reason:'基板表面清洗不彻底',measure:'增加清洗段碱液浓度和冲洗压力'}],status:'done'},{id:'FX-2026-006',confirmId:'QR-2026-006',date:'2026-05-25',handler:'张伟',reasons:[{seq:1,reason:'热轧精轧机组宽度控制模型偏差',measure:'重新标定宽度计,优化模型参数'}],status:'done'},{id:'FX-2026-007',confirmId:'QR-2026-009',date:'2026-06-14',handler:'王芳',reasons:[{seq:1,reason:'物流运输途中固定不牢导致碰撞',measure:'要求物流公司使用专用钢卷支架运输'}],status:'done'}];
let processings=[{id:'CL-2026-001',analysisId:'FX-2026-001',date:'2026-04-12',handler:'王工',processingDetail:'已完成换货处理,新批次钢卷已送达客户,无锈蚀问题。运输全程采用防雨布+干燥剂方案。仓库已新增2台除湿机。',processingFiles:[{name:'换货交接单.pdf',size:'0.5MB'}],status:'done'},{id:'CL-2026-002',analysisId:'FX-2026-002',date:'2026-04-18',handler:'赵工',processingDetail:'供应商已完成AGC系统校准补发批次厚度检测合格。',processingFiles:[],status:'done'},{id:'CL-2026-003',analysisId:'FX-2026-003',date:'2026-04-30',handler:'王工',processingDetail:'探伤检测流程已强化,新增在线探伤设备一台。客户已收到合格替换产品。',processingFiles:[{name:'探伤工艺规程.pdf',size:'1.2MB'}],status:'done'},{id:'CL-2026-004',analysisId:'FX-2026-004',date:'2026-05-14',handler:'赵工',processingDetail:'开平线输送辊清洁完成,增加每周一次深度清洁制度。',processingFiles:[],status:'done'},{id:'CL-2026-005',analysisId:'FX-2026-005',date:'2026-05-20',handler:'孙工',processingDetail:'温控系统已升级新增4个温度监测点。清洗段碱液浓度和冲洗压力已调整。',processingFiles:[{name:'温控系统升级方案.pdf',size:'1.8MB'}],status:'done'},{id:'CL-2026-006',analysisId:'FX-2026-006',date:'2026-05-30',handler:'王工',processingDetail:'宽度计已重新标定模型参数优化后宽度控制精度恢复至±1mm。',processingFiles:[],status:'done'}];
let followups=[{id:'HF-2026-001',processingId:'CL-2026-001',date:'2026-04-16',handler:'张伟',followupDetail:'电话回访客户,确认新批次产品已验收合格,对处理结果表示满意。',confirmContent:'客户确认无质量问题,投诉关闭。',status:'done'},{id:'HF-2026-002',processingId:'CL-2026-002',date:'2026-04-22',handler:'李强',followupDetail:'客户反馈补发冷轧板使用正常,厚度均匀性已改善。',confirmContent:'投诉关闭,客户满意度良好。',status:'done'},{id:'HF-2026-003',processingId:'CL-2026-003',date:'2026-05-05',handler:'张伟',followupDetail:'客户对探伤流程强化表示认可,后续合作信心增强。',confirmContent:'投诉关闭。',status:'done'},{id:'HF-2026-004',processingId:'CL-2026-004',date:'2026-05-20',handler:'李强',followupDetail:'清洁制度执行后,后续批次未再出现划痕问题。',confirmContent:'投诉关闭。',status:'done'},{id:'HF-2026-005',processingId:'CL-2026-005',date:'2026-05-26',handler:'王芳',followupDetail:'回访确认温控升级后锌层附着力达标,客户认可改进效果。',confirmContent:'投诉关闭,客户表示满意。',status:'done'}];
let nComplaint=15,nConfirm=11,nAnalysis=8,nProcessing=7,nFollowup=6;
let curComp=null,curCfm=null,curAnl=null,curPrc=null,curFlp=null;
let tempContractId=null,tempCoilIds=[];
/* ===== 工具函数 ===== */
function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function getC(id){return mockContracts.find(c=>c.id===id);}
function getCo(id){return mockCoils.find(c=>c.id===id);}
function getCp(id){return complaints.find(c=>c.id===id);}
function getCf(id){return confirmations.find(c=>c.id===id);}
function getAn(id){return analyses.find(c=>c.id===id);}
function getPr(id){return processings.find(c=>c.id===id);}
function getFl(id){return followups.find(c=>c.id===id);}
function rvLabel(s){return s==='approved'?'已通过':s==='rejected'?'未通过':'未审核';}
function rvCls(s){return s==='approved'?'sb-ap':s==='rejected'?'sb-rj':'sb-ur';}
function stLabel(s){return s==='done'?'已处理':'未处理';}
function stCls(s){return s==='done'?'sb-dn':'sb-ud';}
function closeModal(id){document.getElementById(id).classList.remove('show');}
function today(){return new Date().toISOString().slice(0,10);}
/* 通用列表项HTML */
function listItem(id,date,preview,statusBadge){return `<div class="complaint-item" onclick=""><div class="ci-id">${id}</div><div class="ci-status">${statusBadge}</div><div class="ci-date">${date}</div><div class="ci-preview">${preview}</div></div>`;}
/* ===== 页面切换 ===== */
function switchPage(p){
document.querySelectorAll('.menu-item').forEach(m=>{m.classList.toggle('active',m.dataset.page===p);});
document.querySelectorAll('.page-view').forEach(v=>v.classList.remove('active'));
document.getElementById('page-'+p).classList.add('active');
if(p==='complaint')renderCompList();if(p==='confirm')renderCfmList();
if(p==='analysis')renderAnlList();if(p==='processing')renderPrcList();
if(p==='followup')renderFlpList();
if(p==='statistics')initStatsPage();
}
/* ===== 投诉受理 ===== */
function renderCompList(){
const el=document.getElementById('complaint-list');
el.innerHTML=complaints.length?complaints.map(c=>`<div class="complaint-item${curComp===c.id?' active':''}" onclick="selectComp('${c.id}')"><div class="ci-id">${c.id}</div><div class="ci-status"><span class="status-badge ${rvCls(c.reviewStatus)}">${rvLabel(c.reviewStatus)}</span></div><div class="ci-date">${c.date}</div><div class="ci-preview">${(c.situation||'').substring(0,28)}</div></div>`).join(''):'<div style="padding:20px;text-align:center;color:#999;">暂无投诉受理单</div>';
}
function selectComp(id){curComp=id;renderCompList();renderCompDetail();}
function createNew(){
const id='TS-2026-'+String(nComplaint++).padStart(3,'0');
complaints.push({id,date:today(),situation:'',demand:'',remark:'',contractId:null,coilIds:[],reviewStatus:'unreviewed',reviewNote:''});
curComp=id;renderCompList();renderCompDetail();
}
function deleteComp(id){
if(!confirm('确认删除投诉受理单 '+id+''))return;
complaints=complaints.filter(c=>c.id!==id);
if(curComp===id)curComp=null;
renderCompList();document.getElementById('comp-detail').style.display='none';document.getElementById('comp-empty').style.display='flex';
}
function renderCompDetail(){
const c=getCp(curComp);if(!c)return;
document.getElementById('comp-empty').style.display='none';document.getElementById('comp-detail').style.display='block';
const ct=c.contractId?getC(c.contractId):null;
const cls=(c.coilIds||[]).map(id=>getCo(id)).filter(Boolean);
let h=`<div class="watermark-top">嘉祥科伦普重工有限公司专用</div><div class="watermark-id">投诉编号:${c.id}</div><h2>投诉受理单</h2>
<div style="margin-bottom:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;"><span class="status-badge ${rvCls(c.reviewStatus)}" style="font-size:13px;padding:4px 14px;">审核状态:${rvLabel(c.reviewStatus)}</span>${c.reviewStatus!=='unreviewed'?`<span style="font-size:12px;color:#666;">审核意见:${esc(c.reviewNote)||'无'}</span>`:''}<div style="flex:1;"></div>`;
if(c.reviewStatus==='unreviewed')h+=`<button class="btn btn-primary" onclick="openReviewModal()">审核</button>`;
else h+=`<button class="btn" onclick="openReviewModal()">${c.reviewStatus==='rejected'?'重新审核':'查看审核'}</button>`;
h+=`</div>`;
h+=`<div class="section"><div class="section-title">第三部分 — 投诉信息</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>投诉编号</label><input value="${c.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>投诉日期</label><input type="date" value="${c.date}" onchange="updateComp('date',this.value)"></div><div class="form-group full"><label>投诉情况</label><textarea oninput="updateComp('situation',this.value)">${esc(c.situation)}</textarea></div><div class="form-group full"><label>客户诉求</label><textarea oninput="updateComp('demand',this.value)">${esc(c.demand)}</textarea></div><div class="form-group full"><label>备注</label><textarea oninput="updateComp('remark',this.value)">${esc(c.remark)}</textarea></div></div></div></div>`;
h+=`<div class="section"><div class="section-title">第一部分 — 合同基础信息</div><div class="section-body"><div style="margin-bottom:8px;"><span style="font-size:12px;font-weight:bold;">挂接合同:</span>${ct?`<span class="tag">${ct.no} ${ct.name}<span class="tag-remove" onclick="removeContract()">&times;</span></span>`:'<span style="color:#999;font-size:12px;">未选择</span>'}<button class="btn btn-sm" style="margin-left:8px;" onclick="openContractModal()">选择合同</button></div>`;
if(ct){h+=`<div class="form-grid"><div class="form-group"><label>合同名称</label><input value="${esc(ct.name)}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>合同编号</label><input value="${ct.no}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>订单金额(元)</label><input value="${ct.amount.toLocaleString()}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>销售员</label><input value="${ct.salesman}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>签订时间</label><input value="${ct.signDate}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>交货日期</label><input value="${ct.deliveryDate}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>签订地点</label><input value="${ct.signPlace}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>产品名称</label><input value="${ct.productName}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>产品厂家</label><input value="${ct.factory}" disabled style="background:#f0f0f0;"></div></div>${renderProductItems(ct.items)}`;}
h+=`</div></div>`;
h+=`<div class="section"><div class="section-title">第二部分 — 钢卷信息</div><div class="section-body"><div style="margin-bottom:8px;"><span style="font-size:12px;font-weight:bold;">挂接钢卷:</span>${cls.length?cls.map(cl=>`<span class="tag">${cl.entryNo}<span class="tag-remove" onclick="removeCoil(${cl.id})">&times;</span></span>`).join(''):'<span style="color:#999;font-size:12px;">未选择</span>'}<button class="btn btn-sm" style="margin-left:8px;" onclick="openCoilModal()">选择钢卷</button></div>${cls.length?renderCoilTable(cls):''}</div></div>`;
h+=`<div class="action-bar"><button class="btn btn-danger" onclick="deleteComp('${c.id}')">删除此单</button><div style="flex:1;"></div><button class="btn btn-primary" onclick="alert('投诉受理单已保存!')">保存</button></div>`;
document.getElementById('comp-detail').innerHTML=h;
}
function updateComp(f,v){const c=getCp(curComp);if(!c)return;c[f]=v;renderCompList();}
/* ===== 审核 ===== */
function openReviewModal(){const c=getCp(curComp);if(!c)return;document.getElementById('review-result').value=c.reviewStatus==='rejected'?'rejected':'approved';document.getElementById('review-note').value=c.reviewNote||'';document.getElementById('modal-review').classList.add('show');}
function doReview(){const c=getCp(curComp);if(!c)return;c.reviewStatus=document.getElementById('review-result').value;c.reviewNote=document.getElementById('review-note').value;closeModal('modal-review');renderCompDetail();renderCompList();alert('审核完成:'+rvLabel(c.reviewStatus));}
/* ===== 投诉确认 ===== */
function renderCfmList(){
const el=document.getElementById('confirm-list');
el.innerHTML=confirmations.length?confirmations.map(c=>`<div class="complaint-item${curCfm===c.id?' active':''}" onclick="selectCfm('${c.id}')"><div class="ci-id">${c.id}</div><div class="ci-status"><span class="status-badge ${stCls(c.status)}">${stLabel(c.status)}</span></div><div class="ci-date">${c.date}</div><div class="ci-preview">来源:${c.complaintId}</div></div>`).join(''):'<div style="padding:20px;text-align:center;color:#999;">暂无投诉确认单</div>';
}
function selectCfm(id){curCfm=id;renderCfmList();renderCfmDetail();}
function createConfirm(){
const approved=complaints.filter(c=>c.reviewStatus==='approved'&&!confirmations.find(q=>q.complaintId===c.id));
if(!approved.length){alert('当前没有已审核通过且未生成确认单的投诉受理单。');return;}
const tb=document.getElementById('import-cfm-tbody');
tb.innerHTML=approved.map(c=>`<tr style="cursor:pointer;" onclick="doImportCfm('${c.id}')"><td>${c.id}</td><td>${c.date}</td><td>${(c.situation||'').substring(0,20)}...</td><td><span class="status-badge sb-ap">已通过</span></td></tr>`).join('');
document.getElementById('modal-import-cfm').classList.add('show');
}
function doImportCfm(cid){closeModal('modal-import-cfm');const id='QR-2026-'+String(nConfirm++).padStart(3,'0');confirmations.push({id,complaintId:cid,date:today(),handler:'',confirmContent:'',confirmResult:'',status:'undone',files:[]});curCfm=id;renderCfmList();renderCfmDetail();}
function deleteCfm(id){if(!confirm('确认删除投诉确认单 '+id+''))return;confirmations=confirmations.filter(c=>c.id!==id);if(curCfm===id)curCfm=null;renderCfmList();document.getElementById('cfm-detail').style.display='none';document.getElementById('cfm-empty').style.display='flex';}
function renderCfmDetail(){
const q=getCf(curCfm);if(!q)return;document.getElementById('cfm-empty').style.display='none';document.getElementById('cfm-detail').style.display='block';
const c=getCp(q.complaintId);const ct=c&&c.contractId?getC(c.contractId):null;const cls=c&&c.coilIds?c.coilIds.map(id=>getCo(id)).filter(Boolean):[];
let h=`<div class="watermark-top">嘉祥科伦普重工有限公司专用</div><div class="watermark-id">确认单号:${q.id} (来源:${q.complaintId}</div><h2>投诉确认单</h2>
<div style="margin-bottom:16px;display:flex;align-items:center;gap:12px;"><span class="status-badge ${stCls(q.status)}" style="font-size:13px;padding:4px 14px;">处理状态:${stLabel(q.status)}</span><div style="flex:1;"></div><button class="btn btn-sm ${q.status==='done'?'':'btn-success'}" onclick="toggleCfmStatus()">${q.status==='done'?'标记为未处理':'标记为已处理'}</button></div>
<div class="section"><div class="section-title">确认信息</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>确认单号</label><input value="${q.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>确认日期</label><input type="date" value="${q.date}" onchange="updateCfm('date',this.value)"></div><div class="form-group"><label>处理人</label><input value="${esc(q.handler)}" oninput="updateCfm('handler',this.value)" placeholder="请输入处理人姓名"></div><div class="form-group full"><label>确认内容</label><textarea oninput="updateCfm('confirmContent',this.value)" placeholder="请填写投诉确认的具体情况和处理方案...">${esc(q.confirmContent)}</textarea></div><div class="form-group full"><label>确认结果</label><textarea oninput="updateCfm('confirmResult',this.value)" placeholder="请填写最终处理结果...">${esc(q.confirmResult)}</textarea></div></div></div></div>`;
if(ct)h+=`<div class="section"><div class="section-title">合同信息(简明)</div><div class="section-body"><div class="form-grid cols4"><div class="form-group"><label>合同编号</label><input value="${ct.no}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>合同名称</label><input value="${esc(ct.name)}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>金额(元)</label><input value="${ct.amount.toLocaleString()}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>销售员</label><input value="${ct.salesman}" disabled style="background:#f0f0f0;"></div></div></div></div>`;
if(cls.length)h+=`<div class="section"><div class="section-title">钢卷信息(简明)</div><div class="section-body"><div class="sub-table-wrap"><table class="sub-table"><thead><tr><th>入场卷号</th><th>规格</th><th>重量(t)</th><th>材质</th><th>厂家</th><th>品质</th></tr></thead><tbody>${cls.map(cl=>`<tr><td>${cl.entryNo}</td><td>${cl.spec}</td><td>${cl.weight}</td><td>${cl.texture}</td><td>${cl.factory}</td><td>${cl.quality}</td></tr>`).join('')}</tbody></table></div></div></div>`;
if(c)h+=`<div class="section"><div class="section-title">投诉信息(简明)</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>投诉编号</label><input value="${c.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>投诉日期</label><input value="${c.date}" disabled style="background:#f0f0f0;"></div><div class="form-group full"><label>投诉情况</label><textarea disabled style="background:#f0f0f0;">${esc(c.situation)}</textarea></div><div class="form-group full"><label>客户诉求</label><textarea disabled style="background:#f0f0f0;">${esc(c.demand)}</textarea></div></div></div></div>`;
h+=`<div class="section"><div class="section-title">附件资料</div><div class="section-body"><div class="file-upload-area" onclick="document.getElementById('cfm-file').click()">点击上传文件<br><span style="font-size:11px;">支持 JPG/PNG/PDF/Word/Excel 等</span></div><input type="file" id="cfm-file" multiple style="display:none;" onchange="uploadCfmFile(this)"><div class="file-list">${(q.files||[]).map((f,i)=>`<div class="file-item"><span class="fi-name">📎 ${f.name}${f.size}</span><span class="fi-remove" onclick="delCfmFile(${i})">&times;</span></div>`).join('')}</div></div></div>`;
h+=`<div class="action-bar"><button class="btn btn-danger" onclick="deleteCfm('${q.id}')">删除</button><div style="flex:1;"></div><button class="btn btn-primary" onclick="alert('已保存!')">保存</button></div>`;
document.getElementById('cfm-detail').innerHTML=h;
}
function updateCfm(f,v){const q=getCf(curCfm);if(!q)return;q[f]=v;renderCfmList();}
function toggleCfmStatus(){const q=getCf(curCfm);if(!q)return;q.status=q.status==='done'?'undone':'done';renderCfmDetail();renderCfmList();}
function uploadCfmFile(inp){const q=getCf(curCfm);if(!q)return;for(const f of inp.files)q.files.push({name:f.name,size:(f.size/1024/1024).toFixed(1)+'MB'});inp.value='';renderCfmDetail();}
function delCfmFile(i){const q=getCf(curCfm);if(!q)return;q.files.splice(i,1);renderCfmDetail();}
/* ===== 投诉分析 ===== */
function renderAnlList(){
const el=document.getElementById('analysis-list');
el.innerHTML=analyses.length?analyses.map(a=>`<div class="complaint-item${curAnl===a.id?' active':''}" onclick="selectAnl('${a.id}')"><div class="ci-id">${a.id}</div><div class="ci-status"><span class="status-badge ${stCls(a.status)}">${stLabel(a.status)}</span></div><div class="ci-date">${a.date}</div><div class="ci-preview">来源:${a.confirmId}</div></div>`).join(''):'<div style="padding:20px;text-align:center;color:#999;">暂无投诉分析单</div>';
}
function selectAnl(id){curAnl=id;renderAnlList();renderAnlDetail();}
function createAnalysis(){
const done=confirmations.filter(c=>c.status==='done'&&!analyses.find(a=>a.confirmId===c.id));
if(!done.length){alert('当前没有已处理且未生成分析单的投诉确认单。');return;}
const tb=document.getElementById('import-anl-tbody');
tb.innerHTML=done.map(c=>`<tr style="cursor:pointer;" onclick="doImportAnl('${c.id}')"><td>${c.id}</td><td>${c.complaintId}</td><td>${c.handler||'-'}</td><td><span class="status-badge sb-dn">已处理</span></td></tr>`).join('');
document.getElementById('modal-import-anl').classList.add('show');
}
function doImportAnl(cfid){closeModal('modal-import-anl');const id='FX-2026-'+String(nAnalysis++).padStart(3,'0');analyses.push({id,confirmId:cfid,date:today(),handler:'',reasons:[],status:'undone'});curAnl=id;renderAnlList();renderAnlDetail();}
function deleteAnl(id){if(!confirm('确认删除投诉分析单 '+id+''))return;analyses=analyses.filter(a=>a.id!==id);if(curAnl===id)curAnl=null;renderAnlList();document.getElementById('anl-detail').style.display='none';document.getElementById('anl-empty').style.display='flex';}
function updateAnl(f,v){const a=getAn(curAnl);if(!a)return;a[f]=v;renderAnlList();}
function toggleAnlStatus(){const a=getAn(curAnl);if(!a)return;a.status=a.status==='done'?'undone':'done';renderAnlDetail();renderAnlList();}
function addAnlReason(){
const a=getAn(curAnl);if(!a)return;a.reasons.push({seq:a.reasons.length+1,reason:'',measure:''});
const tb=document.getElementById('anl-reason-tbody');if(!tb)return renderAnlDetail();
else tb.innerHTML+=`<tr id="anl-r-${a.reasons.length-1}"><td>${a.reasons.length}</td><td><textarea oninput="updateAnlReason(${a.reasons.length-1},'reason',this.value)"></textarea></td><td><textarea oninput="updateAnlReason(${a.reasons.length-1},'measure',this.value)"></textarea></td><td><button class="btn btn-sm btn-danger" onclick="delAnlReason(${a.reasons.length-1})">删除</button></td></tr>`;
}
function updateAnlReason(i,f,v){const a=getAn(curAnl);if(!a||!a.reasons[i])return;a.reasons[i][f]=v;}
function delAnlReason(i){const a=getAn(curAnl);if(!a)return;a.reasons.splice(i,1);a.reasons.forEach((r,idx)=>r.seq=idx+1);renderAnlDetail();}
function renderAnlDetail(){
const a=getAn(curAnl);if(!a)return;document.getElementById('anl-empty').style.display='none';document.getElementById('anl-detail').style.display='block';
const q=getCf(a.confirmId);if(!q)return;
const c=getCp(q.complaintId);const ct=c&&c.contractId?getC(c.contractId):null;const cls=c&&c.coilIds?c.coilIds.map(id=>getCo(id)).filter(Boolean):[];
let h=`<div class="watermark-top">嘉祥科伦普重工有限公司专用</div><div class="watermark-id">分析单号:${a.id} (来源确认单:${a.confirmId},投诉:${q.complaintId}</div><h2>投诉分析单</h2>
<div style="margin-bottom:16px;display:flex;align-items:center;gap:12px;"><span class="status-badge ${stCls(a.status)}" style="font-size:13px;padding:4px 14px;">${stLabel(a.status)}</span><div style="flex:1;"></div><button class="btn btn-sm ${a.status==='done'?'':'btn-success'}" onclick="toggleAnlStatus()">${a.status==='done'?'标记为未处理':'标记为已处理'}</button></div>
<div class="section"><div class="section-title">分析信息</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>分析单号</label><input value="${a.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>分析日期</label><input type="date" value="${a.date}" onchange="updateAnl('date',this.value)"></div><div class="form-group"><label>处理人</label><input value="${esc(a.handler)}" oninput="updateAnl('handler',this.value)"></div></div></div></div>`;
/* 投诉原因及处理措施表格 */
h+=`<div class="section"><div class="section-title">投诉原因及处理措施</div><div class="section-body"><div class="sub-table-wrap"><div class="sub-table-title">原因分析明细 <button class="btn btn-sm" onclick="addAnlReason()">+ 添加行</button></div><table class="sub-table"><thead><tr><th style="width:40px;">序号</th><th>投诉原因</th><th>处理措施</th><th style="width:60px;">操作</th></tr></thead><tbody id="anl-reason-tbody">`;
(a.reasons||[]).forEach((r,i)=>h+=`<tr><td>${r.seq}</td><td><textarea oninput="updateAnlReason(${i},'reason',this.value)">${esc(r.reason)}</textarea></td><td><textarea oninput="updateAnlReason(${i},'measure',this.value)">${esc(r.measure)}</textarea></td><td><button class="btn btn-sm btn-danger" onclick="delAnlReason(${i})">删除</button></td></tr>`);
h+=`</tbody></table></div></div></div>`;
/* 历史信息汇总 */
h+=`<div class="section"><div class="section-title">投诉确认单信息(来源)</div><div class="section-body">
<div class="info-row"><b>确认内容:</b>${esc(q.confirmContent)||'无'}</div><div class="info-row"><b>确认结果:</b>${esc(q.confirmResult)||'无'}</div>
<div class="info-row"><b>附件:</b>${(q.files||[]).map(f=>f.name).join('、')||'无'}</div></div></div>`;
h+=`<div class="section"><div class="section-title">投诉受理及合同钢卷信息(简明)</div><div class="section-body">
<div class="info-row"><b>投诉编号:</b>${c?c.id:'-'} <b>投诉日期:</b>${c?c.date:'-'}</div>
<div class="info-row"><b>投诉情况:</b>${c?esc(c.situation):'-'}</div>
<div class="info-row"><b>合同:</b>${ct?ct.no+' '+ct.name:'无'} <b>金额:</b>${ct?ct.amount.toLocaleString()+'元':'-'}</div>
<div class="info-row"><b>涉及钢卷:</b>${cls.length?cls.map(cl=>cl.entryNo+'('+cl.spec+')').join('、'):'无'}</div></div></div>`;
h+=`<div class="action-bar"><button class="btn btn-danger" onclick="deleteAnl('${a.id}')">删除</button><div style="flex:1;"></div><button class="btn btn-primary" onclick="alert('已保存!')">保存</button></div>`;
document.getElementById('anl-detail').innerHTML=h;
}
/* ===== 投诉处理 ===== */
function renderPrcList(){
const el=document.getElementById('processing-list');
el.innerHTML=processings.length?processings.map(p=>`<div class="complaint-item${curPrc===p.id?' active':''}" onclick="selectPrc('${p.id}')"><div class="ci-id">${p.id}</div><div class="ci-status"><span class="status-badge ${stCls(p.status)}">${stLabel(p.status)}</span></div><div class="ci-date">${p.date}</div><div class="ci-preview">来源:${p.analysisId}</div></div>`).join(''):'<div style="padding:20px;text-align:center;color:#999;">暂无投诉处理单</div>';
}
function selectPrc(id){curPrc=id;renderPrcList();renderPrcDetail();}
function createProcessing(){
const done=analyses.filter(a=>a.status==='done'&&!processings.find(p=>p.analysisId===a.id));
if(!done.length){alert('当前没有已处理且未生成处理单的投诉分析单。');return;}
const tb=document.getElementById('import-prc-tbody');
tb.innerHTML=done.map(a=>{const q=getCf(a.confirmId);return`<tr style="cursor:pointer;" onclick="doImportPrc('${a.id}')"><td>${a.id}</td><td>${q?q.complaintId:'-'}</td><td>${a.handler||'-'}</td><td><span class="status-badge sb-dn">已处理</span></td></tr>`;}).join('');
document.getElementById('modal-import-prc').classList.add('show');
}
function doImportPrc(aid){closeModal('modal-import-prc');const id='CL-2026-'+String(nProcessing++).padStart(3,'0');processings.push({id,analysisId:aid,date:today(),handler:'',processingDetail:'',processingFiles:[],status:'undone'});curPrc=id;renderPrcList();renderPrcDetail();}
function deletePrc(id){if(!confirm('确认删除投诉处理单 '+id+''))return;processings=processings.filter(p=>p.id!==id);if(curPrc===id)curPrc=null;renderPrcList();document.getElementById('prc-detail').style.display='none';document.getElementById('prc-empty').style.display='flex';}
function updatePrc(f,v){const p=getPr(curPrc);if(!p)return;p[f]=v;renderPrcList();}
function togglePrcStatus(){const p=getPr(curPrc);if(!p)return;p.status=p.status==='done'?'undone':'done';renderPrcDetail();renderPrcList();}
function uploadPrcFile(inp){const p=getPr(curPrc);if(!p)return;for(const f of inp.files)p.processingFiles.push({name:f.name,size:(f.size/1024/1024).toFixed(1)+'MB'});inp.value='';renderPrcDetail();}
function delPrcFile(i){const p=getPr(curPrc);if(!p)return;p.processingFiles.splice(i,1);renderPrcDetail();}
function renderPrcDetail(){
const p=getPr(curPrc);if(!p)return;document.getElementById('prc-empty').style.display='none';document.getElementById('prc-detail').style.display='block';
const a=getAn(p.analysisId);if(!a)return;const q=getCf(a.confirmId);if(!q)return;const c=getCp(q.complaintId);
const ct=c&&c.contractId?getC(c.contractId):null;const cls=c&&c.coilIds?c.coilIds.map(id=>getCo(id)).filter(Boolean):[];
let h=`<div class="watermark-top">嘉祥科伦普重工有限公司专用</div><div class="watermark-id">处理单号:${p.id} (来源分析单:${p.analysisId}</div><h2>投诉处理单</h2>
<div style="margin-bottom:16px;display:flex;align-items:center;gap:12px;"><span class="status-badge ${stCls(p.status)}" style="font-size:13px;padding:4px 14px;">${stLabel(p.status)}</span><div style="flex:1;"></div><button class="btn btn-sm ${p.status==='done'?'':'btn-success'}" onclick="togglePrcStatus()">${p.status==='done'?'标记为未处理':'标记为已处理'}</button></div>`;
/* 当前环节编辑区 */
h+=`<div class="section"><div class="section-title">处理信息</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>处理单号</label><input value="${p.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>处理日期</label><input type="date" value="${p.date}" onchange="updatePrc('date',this.value)"></div><div class="form-group"><label>处理人</label><input value="${esc(p.handler)}" oninput="updatePrc('handler',this.value)"></div><div class="form-group full"><label>处理实况</label><textarea oninput="updatePrc('processingDetail',this.value)" placeholder="请填写处理实况...">${esc(p.processingDetail)}</textarea></div></div>
<div class="file-upload-area" onclick="document.getElementById('prc-file').click()">点击上传处理资料<br><span style="font-size:11px;">支持照片、文档等</span></div><input type="file" id="prc-file" multiple style="display:none;" onchange="uploadPrcFile(this)"><div class="file-list">${(p.processingFiles||[]).map((f,i)=>`<div class="file-item"><span class="fi-name">📎 ${f.name}${f.size}</span><span class="fi-remove" onclick="delPrcFile(${i})">&times;</span></div>`).join('')}</div></div></div>`;
/* 历史信息分类展示 */
h+=`<div class="section"><div class="section-title">【投诉分析】原因及措施</div><div class="section-body"><table class="sub-table"><thead><tr><th>序号</th><th>投诉原因</th><th>处理措施</th></tr></thead><tbody>${(a.reasons||[]).map(r=>`<tr><td>${r.seq}</td><td>${esc(r.reason)}</td><td>${esc(r.measure)}</td></tr>`).join('')}</tbody></table></div></div>`;
h+=`<div class="section"><div class="section-title">【投诉确认】确认信息</div><div class="section-body"><div class="info-row"><b>确认内容:</b>${esc(q.confirmContent)||'无'}</div><div class="info-row"><b>确认结果:</b>${esc(q.confirmResult)||'无'}</div><div class="info-row"><b>附件:</b>${(q.files||[]).map(f=>f.name).join('、')||'无'}</div></div></div>`;
h+=`<div class="section"><div class="section-title">【投诉受理】投诉及合同钢卷</div><div class="section-body"><div class="info-row"><b>投诉编号:</b>${c?c.id:'-'} <b>投诉情况:</b>${c?esc(c.situation):'-'}</div><div class="info-row"><b>客户诉求:</b>${c?esc(c.demand):'-'}</div><div class="info-row"><b>合同:</b>${ct?ct.no+' '+ct.name:'无'} <b>金额:</b>${ct?ct.amount.toLocaleString()+'元':'-'} <b>销售员:</b>${ct?ct.salesman:'-'}</div><div class="info-row"><b>涉及钢卷:</b>${cls.length?cls.map(cl=>cl.entryNo+'('+cl.spec+'/'+cl.texture+')').join('、'):'无'}</div></div></div>`;
h+=`<div class="action-bar"><button class="btn btn-danger" onclick="deletePrc('${p.id}')">删除</button><div style="flex:1;"></div><button class="btn btn-primary" onclick="alert('已保存!')">保存</button></div>`;
document.getElementById('prc-detail').innerHTML=h;
}
/* ===== 回访确认 ===== */
function renderFlpList(){
const el=document.getElementById('followup-list');
el.innerHTML=followups.length?followups.map(f=>`<div class="complaint-item${curFlp===f.id?' active':''}" onclick="selectFlp('${f.id}')"><div class="ci-id">${f.id}</div><div class="ci-status"><span class="status-badge ${stCls(f.status)}">${stLabel(f.status)}</span></div><div class="ci-date">${f.date}</div><div class="ci-preview">来源:${f.processingId}</div></div>`).join(''):'<div style="padding:20px;text-align:center;color:#999;">暂无回访确认单</div>';
}
function selectFlp(id){curFlp=id;renderFlpList();renderFlpDetail();}
function createFollowup(){
const done=processings.filter(p=>p.status==='done'&&!followups.find(f=>f.processingId===p.id));
if(!done.length){alert('当前没有已处理且未生成回访单的投诉处理单。');return;}
const tb=document.getElementById('import-flp-tbody');
tb.innerHTML=done.map(p=>{const a=getAn(p.analysisId);return`<tr style="cursor:pointer;" onclick="doImportFlp('${p.id}')"><td>${p.id}</td><td>${a?a.id:'-'}</td><td>${p.handler||'-'}</td><td><span class="status-badge sb-dn">已处理</span></td></tr>`;}).join('');
document.getElementById('modal-import-flp').classList.add('show');
}
function doImportFlp(pid){closeModal('modal-import-flp');const id='HF-2026-'+String(nFollowup++).padStart(3,'0');followups.push({id,processingId:pid,date:today(),handler:'',followupDetail:'',confirmContent:'',status:'undone'});curFlp=id;renderFlpList();renderFlpDetail();}
function deleteFlp(id){if(!confirm('确认删除回访确认单 '+id+''))return;followups=followups.filter(f=>f.id!==id);if(curFlp===id)curFlp=null;renderFlpList();document.getElementById('flp-detail').style.display='none';document.getElementById('flp-empty').style.display='flex';}
function updateFlp(f,v){const fp=getFl(curFlp);if(!fp)return;fp[f]=v;renderFlpList();}
function toggleFlpStatus(){const fp=getFl(curFlp);if(!fp)return;fp.status=fp.status==='done'?'undone':'done';renderFlpDetail();renderFlpList();}
function renderFlpDetail(){
const f=getFl(curFlp);if(!f)return;document.getElementById('flp-empty').style.display='none';document.getElementById('flp-detail').style.display='block';
const p=getPr(f.processingId);if(!p)return;const a=getAn(p.analysisId);if(!a)return;const q=getCf(a.confirmId);if(!q)return;const c=getCp(q.complaintId);
const ct=c&&c.contractId?getC(c.contractId):null;const cls=c&&c.coilIds?c.coilIds.map(id=>getCo(id)).filter(Boolean):[];
let h=`<div class="watermark-top">嘉祥科伦普重工有限公司专用</div><div class="watermark-id">回访单号:${f.id} (来源处理单:${f.processingId}</div><h2>回访确认单</h2>
<div style="margin-bottom:16px;display:flex;align-items:center;gap:12px;"><span class="status-badge ${stCls(f.status)}" style="font-size:13px;padding:4px 14px;">${stLabel(f.status)}</span><div style="flex:1;"></div><button class="btn btn-sm ${f.status==='done'?'':'btn-success'}" onclick="toggleFlpStatus()">${f.status==='done'?'标记为未处理':'标记为已处理'}</button></div>`;
/* 回访编辑区 */
h+=`<div class="section"><div class="section-title">回访信息</div><div class="section-body"><div class="form-grid cols2"><div class="form-group"><label>回访单号</label><input value="${f.id}" disabled style="background:#f0f0f0;"></div><div class="form-group"><label>回访日期</label><input type="date" value="${f.date}" onchange="updateFlp('date',this.value)"></div><div class="form-group"><label>回访人</label><input value="${esc(f.handler)}" oninput="updateFlp('handler',this.value)"></div><div class="form-group full"><label>回访实况</label><textarea oninput="updateFlp('followupDetail',this.value)" placeholder="请填写回访实况...">${esc(f.followupDetail)}</textarea></div><div class="form-group full"><label>处理确认</label><textarea oninput="updateFlp('confirmContent',this.value)" placeholder="请填写处理确认内容...">${esc(f.confirmContent)}</textarea></div></div></div></div>`;
/* 历史信息汇总 */
h+=`<div class="section"><div class="section-title">【投诉处理】处理实况</div><div class="section-body"><div class="info-row"><b>处理实况:</b>${esc(p.processingDetail)||'无'}</div><div class="info-row"><b>处理资料:</b>${(p.processingFiles||[]).map(f=>f.name).join('、')||'无'}</div></div></div>`;
h+=`<div class="section"><div class="section-title">【投诉分析】原因及措施</div><div class="section-body"><table class="sub-table"><thead><tr><th>序号</th><th>投诉原因</th><th>处理措施</th></tr></thead><tbody>${(a.reasons||[]).map(r=>`<tr><td>${r.seq}</td><td>${esc(r.reason)}</td><td>${esc(r.measure)}</td></tr>`).join('')}</tbody></table></div></div>`;
h+=`<div class="section"><div class="section-title">【投诉确认】确认信息</div><div class="section-body"><div class="info-row"><b>确认内容:</b>${esc(q.confirmContent)||'无'}</div><div class="info-row"><b>确认结果:</b>${esc(q.confirmResult)||'无'}</div></div></div>`;
h+=`<div class="section"><div class="section-title">【投诉受理】投诉及合同钢卷</div><div class="section-body"><div class="info-row"><b>投诉编号:</b>${c?c.id:'-'} <b>情况:</b>${c?esc(c.situation):'-'}</div><div class="info-row"><b>诉求:</b>${c?esc(c.demand):'-'}</div><div class="info-row"><b>合同:</b>${ct?ct.no+' '+esc(ct.name):'无'} <b>金额:</b>${ct?ct.amount.toLocaleString()+'元':'-'}</div></div></div>`;
h+=`<div class="action-bar"><button class="btn btn-danger" onclick="deleteFlp('${f.id}')">删除</button><div style="flex:1;"></div><button class="btn btn-primary" onclick="alert('已保存!')">保存</button></div>`;
document.getElementById('flp-detail').innerHTML=h;
}
/* ===== 共用渲染函数 ===== */
function renderProductItems(items){return`<div class="sub-table-wrap"><div class="sub-table-title">产品明细</div><table class="sub-table"><thead><tr><th>序号</th><th>规格(mm)</th><th>材质</th><th>数量(吨)</th><th>含税单价</th><th>税率除数</th><th>无税单价</th><th>含税总额</th><th>无税总额</th><th>税额</th><th>备注</th></tr></thead><tbody>${items.map(it=>`<tr><td>${it.seq}</td><td>${it.spec}</td><td>${it.material}</td><td>${it.qty}</td><td>${it.priceTax}</td><td>${it.rate}</td><td>${it.priceNoTax.toFixed(2)}</td><td>${it.totalTax.toLocaleString()}</td><td>${it.totalNoTax.toFixed(2)}</td><td>${it.taxAmount.toFixed(2)}</td><td>${it.remark||''}</td></tr>`).join('')}</tbody></table></div>`;}
function renderCoilTable(cl){return`<div class="sub-table-wrap" style="overflow-x:auto;"><div class="sub-table-title">钢卷明细</div><table class="sub-table"><thead><tr><th>入场卷号</th><th>当前卷号</th><th>镀铬卷号</th><th>存储位置</th><th>物料</th><th>规格</th><th>重量(t)</th><th>材质</th><th>厂家</th><th>库区</th><th>创建时间</th><th>品质</th><th>表面处理</th><th>备注</th><th>切边</th><th>包装</th><th>镀层质量</th></tr></thead><tbody>${cl.map(c=>`<tr><td>${c.entryNo}</td><td>${c.currentNo}</td><td>${c.chromeNo}</td><td>${c.location}</td><td>${c.material}</td><td>${c.spec}</td><td>${c.weight}</td><td>${c.texture}</td><td>${c.factory}</td><td>${c.zone}</td><td>${c.createTime}</td><td>${c.quality}</td><td>${c.surface}</td><td>${c.remark||''}</td><td>${c.trim}</td><td>${c.packaging}</td><td>${c.coatingQuality}</td></tr>`).join('')}</tbody></table></div>`;}
/* ===== 合同弹窗 ===== */
function openContractModal(){const c=getCp(curComp);tempContractId=c&&c.contractId?c.contractId:null;renderContractTable('');document.getElementById('modal-contract').classList.add('show');}
function filterContracts(k){renderContractTable(k);}
function renderContractTable(k){const tb=document.querySelector('#contract-table tbody');const f=mockContracts.filter(c=>!k||c.name.includes(k)||c.no.includes(k));tb.innerHTML=f.map(c=>`<tr class="${tempContractId===c.id?'selected':''}" onclick="tempContractId=${c.id};renderContractTable(document.querySelector('#modal-contract input').value)"><td>${c.no}</td><td>${c.name}</td><td>${c.salesman}</td><td>${c.signDate}</td></tr>`).join('');}
function confirmContract(){if(!tempContractId){alert('请先选择合同');return;}const c=getCp(curComp);if(!c)return;c.contractId=tempContractId;c.coilIds=[];closeModal('modal-contract');renderCompDetail();}
function removeContract(){const c=getCp(curComp);if(!c)return;c.contractId=null;c.coilIds=[];renderCompDetail();}
/* ===== 钢卷弹窗 ===== */
function openCoilModal(){tempCoilIds=[...(getCp(curComp)?.coilIds||[])];renderCoilModalTable('');document.getElementById('modal-coil').classList.add('show');}
function filterCoils(k){renderCoilModalTable(k);}
function renderCoilModalTable(k){const tb=document.querySelector('#coil-table tbody');const f=mockCoils.filter(c=>!k||c.entryNo.includes(k)||c.spec.includes(k)||c.texture.includes(k));tb.innerHTML=f.map(c=>`<tr class="${tempCoilIds.includes(c.id)?'selected':''}" onclick="toggleCoil(${c.id});renderCoilModalTable(document.querySelector('#modal-coil input').value)"><td>${c.entryNo}</td><td>${c.spec}</td><td>${c.texture}</td><td>${c.weight}</td><td>${c.factory}</td></tr>`).join('');}
function toggleCoil(id){if(tempCoilIds.includes(id))tempCoilIds=tempCoilIds.filter(i=>i!==id);else tempCoilIds.push(id);}
function confirmCoils(){const c=getCp(curComp);if(!c)return;c.coilIds=[...tempCoilIds];closeModal('modal-coil');renderCompDetail();}
function removeCoil(id){const c=getCp(curComp);if(!c)return;c.coilIds=c.coilIds.filter(i=>i!==id);renderCompDetail();}
/* ===== 数据分析 ===== */
let statsCharts={};
function initStatsPage(){
const now=new Date();
if(!document.getElementById('stats-start').value){
document.getElementById('stats-start').value=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-01';
document.getElementById('stats-end').value=now.toISOString().slice(0,10);
}
refreshStats();
}
function resetStatsDate(){
const now=new Date();
document.getElementById('stats-start').value=now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-01';
document.getElementById('stats-end').value=now.toISOString().slice(0,10);
refreshStats();
}
function getStatsRange(){
const s=document.getElementById('stats-start').value;
const e=document.getElementById('stats-end').value;
return{start:s||'2026-01-01',end:e||today()};
}
function inRange(d,range){return d>=range.start&&d<=range.end;}
function dateAdd(d,days){const r=new Date(d);r.setDate(r.getDate()+days);return r.toISOString().slice(0,10);}
function refreshStats(){
Object.values(statsCharts).forEach(c=>c.destroy());
statsCharts={};
const range=getStatsRange();
const fc=complaints.filter(c=>inRange(c.date,range));
const fcf=confirmations.filter(c=>inRange(c.date,range));
const fan=analyses.filter(c=>inRange(c.date,range));
const fpr=processings.filter(c=>inRange(c.date,range));
const ffl=followups.filter(c=>inRange(c.date,range));
/* 金额关联 */
const complaintAmounts=fc.map(c=>{const ct=c.contractId?getC(c.contractId):null;return ct?ct.amount:0;});
const totalAmount=complaintAmounts.reduce((a,b)=>a+b,0);
const avgAmount=fc.length?totalAmount/fc.length:0;
const salesmen=[...new Set(fc.map(c=>{const ct=c.contractId?getC(c.contractId):null;return ct?ct.salesman:null;}).filter(Boolean))];
/* ===== 一、基本统计 ===== */
document.getElementById('stats-basic').innerHTML=[
{v:salesmen.length,l:'涉及业务员数',c:''},{v:fc.length,l:'投诉数量',c:''},
{v:(totalAmount/10000).toFixed(1)+'万',l:'投诉总金额',c:'sc-red'},{v:(avgAmount/10000).toFixed(1)+'万',l:'平均投诉金额',c:'sc-orange'},
{v:fc.length,l:'受理单新增',c:''},{v:fcf.length,l:'确认单新增',c:''},{v:fan.length,l:'分析单新增',c:''},{v:fpr.length,l:'处理单新增',c:''},{v:ffl.length,l:'回访单新增',c:''},
{v:fc.filter(c=>c.reviewStatus!=='unreviewed').length,l:'受理单已处理',c:'sc-green'},{v:fc.filter(c=>c.reviewStatus==='unreviewed').length,l:'受理单未处理',c:'sc-orange'},
{v:fcf.filter(c=>c.status==='done').length,l:'确认单已处理',c:'sc-green'},{v:fcf.filter(c=>c.status==='undone').length,l:'确认单未处理',c:'sc-orange'},
{v:fan.filter(c=>c.status==='done').length,l:'分析单已处理',c:'sc-green'},{v:fan.filter(c=>c.status==='undone').length,l:'分析单未处理',c:'sc-orange'},
{v:fpr.filter(c=>c.status==='done').length,l:'处理单已处理',c:'sc-green'},{v:fpr.filter(c=>c.status==='undone').length,l:'处理单未处理',c:'sc-orange'},
{v:ffl.filter(c=>c.status==='done').length,l:'回访单已处理',c:'sc-green'},{v:ffl.filter(c=>c.status==='undone').length,l:'回访单未处理',c:'sc-orange'}
].map(c=>`<div class="stat-card"><div class="sc-num ${c.c}">${c.v}</div><div class="sc-label">${c.l}</div></div>`).join('');
/* ===== 二、日趋势分析 ===== */
const dailyMap={};
for(let d=range.start;d<=range.end;d=dateAdd(d,1)){
dailyMap[d]={date:d,cnt:0,sales:new Set(),amt:0,comp:0,cfm:0,anl:0,prc:0,flp:0};
}
fc.forEach(c=>{if(dailyMap[c.date]){dailyMap[c.date].cnt++;dailyMap[c.date].comp++;const ct=c.contractId?getC(c.contractId):null;if(ct){dailyMap[c.date].amt+=ct.amount;dailyMap[c.date].sales.add(ct.salesman);}}});
fcf.forEach(c=>{if(dailyMap[c.date])dailyMap[c.date].cfm++;});
fan.forEach(c=>{if(dailyMap[c.date])dailyMap[c.date].anl++;});
fpr.forEach(c=>{if(dailyMap[c.date])dailyMap[c.date].prc++;});
ffl.forEach(c=>{if(dailyMap[c.date])dailyMap[c.date].flp++;});
let dailyArr=Object.values(dailyMap).sort((a,b)=>a.date.localeCompare(b.date));
const maxDaily=30;if(dailyArr.length>maxDaily)dailyArr=dailyArr.slice(-maxDaily);
const dLabels=dailyArr.map(d=>d.date.slice(5));
/* 日趋势图1投诉量&业务员 */
const ctx1=document.getElementById('chart-daily-count').getContext('2d');
statsCharts.dailyCount=new Chart(ctx1,{type:'line',data:{labels:dLabels,datasets:[
{label:'投诉量',data:dailyArr.map(d=>d.cnt),borderColor:'#000',backgroundColor:'rgba(0,0,0,0.05)',fill:true,tension:0.2,pointRadius:3,pointBackgroundColor:'#000'},
{label:'业务员数',data:dailyArr.map(d=>d.sales.size),borderColor:'#c00',backgroundColor:'rgba(200,0,0,0.05)',fill:true,tension:0.2,pointRadius:3,pointBackgroundColor:'#c00',borderDash:[5,3]}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* 日趋势图2投诉金额 */
const ctx2=document.getElementById('chart-daily-amount').getContext('2d');
statsCharts.dailyAmount=new Chart(ctx2,{type:'bar',data:{labels:dLabels,datasets:[
{label:'日总金额(万元)',data:dailyArr.map(d=>(d.amt/10000)),backgroundColor:'#333',borderColor:'#000',borderWidth:1},
{label:'日均金额(万元)',data:dailyArr.map(d=>d.cnt?(d.amt/d.cnt/10000):0),backgroundColor:'#999',borderColor:'#666',borderWidth:1}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* 日趋势图3各环节单据 */
const ctx3=document.getElementById('chart-daily-docs').getContext('2d');
statsCharts.dailyDocs=new Chart(ctx3,{type:'line',data:{labels:dLabels,datasets:[
{label:'受理',data:dailyArr.map(d=>d.comp),borderColor:'#000',tension:0.2,pointRadius:2},
{label:'确认',data:dailyArr.map(d=>d.cfm),borderColor:'#c00',tension:0.2,pointRadius:2},
{label:'分析',data:dailyArr.map(d=>d.anl),borderColor:'#090',tension:0.2,pointRadius:2},
{label:'处理',data:dailyArr.map(d=>d.prc),borderColor:'#06c',tension:0.2,pointRadius:2},
{label:'回访',data:dailyArr.map(d=>d.flp),borderColor:'#c60',tension:0.2,pointRadius:2}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* ===== 三、月均分析近6个月 ===== */
const now=new Date();
let months=[];
for(let i=5;i>=0;i--){
const m=new Date(now.getFullYear(),now.getMonth()-i,1);
months.push({label:m.getFullYear()+'-'+String(m.getMonth()+1).padStart(2,'0'),ym:m.toISOString().slice(0,7),cnt:0,sales:new Set(),amt:0,comp:0,cfm:0,anl:0,prc:0,flp:0});
}
function ym(d){return d.slice(0,7);}
fc.forEach(c=>{const m=months.find(mo=>mo.ym===ym(c.date));if(m){m.cnt++;m.comp++;const ct=c.contractId?getC(c.contractId):null;if(ct){m.amt+=ct.amount;m.sales.add(ct.salesman);}}});
fcf.forEach(c=>{const m=months.find(mo=>mo.ym===ym(c.date));if(m)m.cfm++;});
fan.forEach(c=>{const m=months.find(mo=>mo.ym===ym(c.date));if(m)m.anl++;});
fpr.forEach(c=>{const m=months.find(mo=>mo.ym===ym(c.date));if(m)m.prc++;});
ffl.forEach(c=>{const m=months.find(mo=>mo.ym===ym(c.date));if(m)m.flp++;});
const mLabels=months.map(m=>m.label);
/* 月图1投诉量&业务员 */
const ctx4=document.getElementById('chart-monthly-count').getContext('2d');
statsCharts.monthlyCount=new Chart(ctx4,{type:'bar',data:{labels:mLabels,datasets:[
{label:'投诉量',data:months.map(m=>m.cnt),backgroundColor:'#333',borderColor:'#000',borderWidth:1},
{label:'业务员数',data:months.map(m=>m.sales.size),backgroundColor:'#c00',borderColor:'#900',borderWidth:1}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* 月图2投诉金额 */
const ctx5=document.getElementById('chart-monthly-amount').getContext('2d');
statsCharts.monthlyAmount=new Chart(ctx5,{type:'bar',data:{labels:mLabels,datasets:[
{label:'月总金额(万元)',data:months.map(m=>(m.amt/10000)),backgroundColor:'#555',borderColor:'#000',borderWidth:1},
{label:'月均金额(万元)',data:months.map(m=>m.cnt?(m.amt/m.cnt/10000):0),backgroundColor:'#aaa',borderColor:'#666',borderWidth:1}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* 月图3各环节单据 */
const ctx6=document.getElementById('chart-monthly-docs').getContext('2d');
statsCharts.monthlyDocs=new Chart(ctx6,{type:'bar',data:{labels:mLabels,datasets:[
{label:'受理',data:months.map(m=>m.comp),backgroundColor:'#000'},
{label:'确认',data:months.map(m=>m.cfm),backgroundColor:'#c00'},
{label:'分析',data:months.map(m=>m.anl),backgroundColor:'#090'},
{label:'处理',data:months.map(m=>m.prc),backgroundColor:'#06c'},
{label:'回访',data:months.map(m=>m.flp),backgroundColor:'#c60'}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:20,padding:12,font:{size:11}}}}}});
/* ===== 四、占比分析 ===== */
/* 占比卡片 */
const revApproved=fc.filter(c=>c.reviewStatus==='approved').length;
const revRejected=fc.filter(c=>c.reviewStatus==='rejected').length;
const revUnreviewed=fc.filter(c=>c.reviewStatus==='unreviewed').length;
const totalDocs=fcf.length+fan.length+fpr.length+ffl.length;
const doneDocs=fcf.filter(c=>c.status==='done').length+fan.filter(c=>c.status==='done').length+fpr.filter(c=>c.status==='done').length+ffl.filter(c=>c.status==='done').length;
document.getElementById('stats-proportion-cards').innerHTML=[
{v:revApproved,l:'审核通过',c:'sc-green'},{v:revRejected,l:'审核未通过',c:'sc-red'},
{v:revUnreviewed,l:'待审核',c:'sc-orange'},{v:fc.length?(revApproved/fc.length*100).toFixed(0)+'%':'-',l:'审核通过率',c:''},
{v:doneDocs,l:'下游单据已处理',c:'sc-green'},{v:totalDocs-doneDocs,l:'下游单据未处理',c:'sc-orange'},
{v:totalDocs?(doneDocs/totalDocs*100).toFixed(0)+'%':'-',l:'整体处理率',c:''},{v:salesmen.length,l:'涉及业务员',c:''}
].map(c=>`<div class="stat-card"><div class="sc-num ${c.c}">${c.v}</div><div class="sc-label">${c.l}</div></div>`).join('');
/* 饼图1投诉原因分类基于分析单reasons关键词归类 */
const reasonCats={};
const allReasons=fan.flatMap(a=>a.reasons||[]);
const catRules=[
{cat:'表面质量',kw:['表面','锈蚀','划痕','白斑','氧化','斑点','外观']},
{cat:'尺寸偏差',kw:['厚度','宽度','规格','偏差','超差','不均匀']},
{cat:'镀层质量',kw:['镀层','镀锌','锌层','附着力','涂镀','锌液']},
{cat:'材质性能',kw:['硬度','强度','屈服','力学','材质']},
{cat:'板型缺陷',kw:['板型','波浪','平整度','边部','开裂','裂纹']},
{cat:'包装运输',kw:['包装','运输','破损','进水','碰撞','物流','变形']},
{cat:'工艺缺陷',kw:['氧化皮','酸洗','工艺','辊缝']}
];
allReasons.forEach(r=>{
let matched=false;
for(const cr of catRules){if(cr.kw.some(k=>r.reason.includes(k))){reasonCats[cr.cat]=(reasonCats[cr.cat]||0)+1;matched=true;break;}}
if(!matched)reasonCats['其他']=(reasonCats['其他']||0)+1;
});
if(!allReasons.length){reasonCats['(无分析数据)']=1;}
const ctx7=document.getElementById('chart-reason-pie').getContext('2d');
const pieColors=['#333','#666','#999','#c00','#c60','#090','#06c','#c9c'];
statsCharts.reasonPie=new Chart(ctx7,{type:'doughnut',data:{labels:Object.keys(reasonCats),datasets:[{data:Object.values(reasonCats),backgroundColor:pieColors.slice(0,Object.keys(reasonCats).length),borderColor:'#000',borderWidth:2}]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:14,padding:10,font:{size:11}}}}}});
/* 饼图2产品类型投诉占比 */
const productCats={};
fc.forEach(c=>{const ct=c.contractId?getC(c.contractId):null;const pn=ct?ct.productName:'未知';productCats[pn]=(productCats[pn]||0)+1;});
if(!Object.keys(productCats).length)productCats['无数据']=1;
const ctx8=document.getElementById('chart-product-pie').getContext('2d');
statsCharts.productPie=new Chart(ctx8,{type:'doughnut',data:{labels:Object.keys(productCats),datasets:[{data:Object.values(productCats),backgroundColor:pieColors.slice(0,Object.keys(productCats).length),borderColor:'#000',borderWidth:2}]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:14,padding:10,font:{size:11}}}}}});
/* 柱状图3各环节单据处理率 */
const stages=[
{n:'投诉受理',t:fc.length,d:fc.filter(c=>c.reviewStatus!=='unreviewed').length},
{n:'投诉确认',t:fcf.length,d:fcf.filter(c=>c.status==='done').length},
{n:'投诉分析',t:fan.length,d:fan.filter(c=>c.status==='done').length},
{n:'投诉处理',t:fpr.length,d:fpr.filter(c=>c.status==='done').length},
{n:'回访确认',t:ffl.length,d:ffl.filter(c=>c.status==='done').length}
];
const ctx9=document.getElementById('chart-stage-rate').getContext('2d');
statsCharts.stageRate=new Chart(ctx9,{type:'bar',data:{labels:stages.map(s=>s.n),datasets:[
{label:'总数',data:stages.map(s=>s.t),backgroundColor:'#999',borderColor:'#000',borderWidth:1},
{label:'已处理',data:stages.map(s=>s.d),backgroundColor:'#090',borderColor:'#060',borderWidth:1}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:14,padding:10,font:{size:11}}}}}});
/* 柱状图4业务员投诉分布 */
const smMap={}; fc.forEach(c=>{const ct=c.contractId?getC(c.contractId):null;const sm=ct?ct.salesman:'未知';if(!smMap[sm])smMap[sm]={cnt:0,amt:0};smMap[sm].cnt++;smMap[sm].amt+=ct?ct.amount:0;});
const smEntries=Object.entries(smMap).sort((a,b)=>b[1].cnt-a[1].cnt);
const ctx10=document.getElementById('chart-salesman-bar').getContext('2d');
statsCharts.salesmanBar=new Chart(ctx10,{type:'bar',data:{labels:smEntries.map(e=>e[0]),datasets:[
{label:'投诉数',data:smEntries.map(e=>e[1].cnt),backgroundColor:'#333',borderColor:'#000',borderWidth:1},
{label:'金额(万元)',data:smEntries.map(e=>(e[1].amt/10000)),backgroundColor:'#c00',borderColor:'#900',borderWidth:1}
]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:14,padding:10,font:{size:11}}}}}});
/* 审核通过率环形图 */
const ctx11=document.getElementById('chart-review-rate').getContext('2d');
statsCharts.reviewRate=new Chart(ctx11,{type:'doughnut',data:{labels:['已通过','未通过','待审核'],datasets:[{data:[revApproved,revRejected,revUnreviewed],backgroundColor:['#090','#c00','#c60'],borderColor:'#000',borderWidth:2}]},options:{responsive:true,plugins:{legend:{position:'bottom',labels:{boxWidth:14,padding:10,font:{size:11}}}}}});
}
/* ===== 初始化 ===== */
renderCompList();
</script>
</body>
</html>