生产模块

This commit is contained in:
朱昊天
2026-05-30 14:25:40 +08:00
parent ae586d1fc2
commit 303092e637
21 changed files with 1564 additions and 3 deletions

View File

@@ -0,0 +1,57 @@
package com.gear.mes.production.controller;
import com.gear.common.core.controller.BaseController;
import com.gear.common.core.domain.AjaxResult;
import com.gear.common.core.domain.R;
import com.gear.common.core.page.TableDataInfo;
import com.gear.mes.production.domain.GearProductionTask;
import com.gear.mes.production.domain.vo.GearProductionTaskListVo;
import com.gear.mes.production.domain.vo.GearProductionTaskWithDetailVo;
import com.gear.mes.production.service.IGearProductionTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/mes/production/task")
public class GearProductionTaskController extends BaseController {
@Autowired
private IGearProductionTaskService productionTaskService;
@GetMapping("/list")
public TableDataInfo<GearProductionTaskListVo> list(GearProductionTask query) {
startPage();
List<GearProductionTaskListVo> list = productionTaskService.selectTaskList(query);
return getDataTable(list);
}
@GetMapping("/{taskId}")
public AjaxResult getInfo(@PathVariable("taskId") Long taskId) {
return AjaxResult.success(productionTaskService.selectTaskWithDetail(taskId));
}
@PostMapping
public R<Long> add(@RequestBody GearProductionTaskWithDetailVo bo) {
Long taskId = productionTaskService.insertTaskWithDetail(bo);
if (taskId == null) {
return R.fail("新增失败");
}
return R.ok(taskId);
}
@PostMapping("/{taskId}/complete")
public R<Void> complete(@PathVariable("taskId") Long taskId) {
boolean ok = productionTaskService.completeTask(taskId);
if (!ok) {
return R.fail("完成失败");
}
return R.ok();
}
}

View File

@@ -0,0 +1,39 @@
package com.gear.mes.production.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.gear.common.core.domain.BaseEntity;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;
@Data
public class GearProductionTask extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long taskId;
private String taskCode;
private String taskName;
private String status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date planStartTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date planEndTime;
private String remark;
private String delFlag;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date beginTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
}

View File

@@ -0,0 +1,24 @@
package com.gear.mes.production.domain;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class GearProductionTaskMaterial {
private Long lineId;
private Long taskId;
private Long materialId;
private String materialRole;
private BigDecimal planQty;
private BigDecimal usedQty;
private String unit;
private String remark;
}

View File

@@ -0,0 +1,25 @@
package com.gear.mes.production.domain;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class GearProductionTaskProduct {
private Long lineId;
private Long taskId;
private Long productId;
private BigDecimal planQty;
private BigDecimal finishedQty;
private BigDecimal badQty;
private String unit;
private String remark;
}

View File

@@ -0,0 +1,41 @@
package com.gear.mes.production.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class GearProductionTaskListVo {
private Long taskId;
private String taskCode;
private String taskName;
private String status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date planStartTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date planEndTime;
private String remark;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
private BigDecimal planQty;
private BigDecimal finishedQty;
private BigDecimal badQty;
private BigDecimal unfinishedQty;
}

View File

@@ -0,0 +1,34 @@
package com.gear.mes.production.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class GearProductionTaskMaterialVo {
private Long lineId;
private Long taskId;
private Long materialId;
private String materialRole;
private BigDecimal planQty;
private BigDecimal usedQty;
private String unit;
private String remark;
private String materialName;
private Integer materialType;
private String spec;
private String model;
private String factory;
}

View File

@@ -0,0 +1,35 @@
package com.gear.mes.production.domain.vo;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class GearProductionTaskProductVo {
private Long lineId;
private Long taskId;
private Long productId;
private BigDecimal planQty;
private BigDecimal finishedQty;
private BigDecimal badQty;
private String unit;
private String remark;
private String productName;
private String productCode;
private String productType;
private String spec;
private String model;
}

View File

@@ -0,0 +1,16 @@
package com.gear.mes.production.domain.vo;
import com.gear.mes.production.domain.GearProductionTask;
import lombok.Data;
import java.util.List;
@Data
public class GearProductionTaskWithDetailVo {
private GearProductionTask task;
private List<GearProductionTaskProductVo> products;
private List<GearProductionTaskMaterialVo> materials;
}

View File

@@ -0,0 +1,23 @@
package com.gear.mes.production.mapper;
import com.gear.mes.production.domain.GearProductionTask;
import com.gear.mes.production.domain.vo.GearProductionTaskListVo;
import org.apache.ibatis.annotations.Param;
import java.util.Date;
import java.util.List;
public interface GearProductionTaskMapper {
List<GearProductionTaskListVo> selectTaskList(GearProductionTask query);
GearProductionTask selectTaskById(@Param("taskId") Long taskId);
int insertTask(GearProductionTask task);
int updateTask(GearProductionTask task);
int updateTaskStatus(@Param("taskId") Long taskId,
@Param("status") String status,
@Param("updateBy") String updateBy,
@Param("updateTime") Date updateTime);
}

View File

@@ -0,0 +1,17 @@
package com.gear.mes.production.mapper;
import com.gear.mes.production.domain.GearProductionTaskMaterial;
import com.gear.mes.production.domain.vo.GearProductionTaskMaterialVo;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface GearProductionTaskMaterialMapper {
List<GearProductionTaskMaterialVo> selectByTaskId(@Param("taskId") Long taskId);
List<GearProductionTaskMaterial> selectRequirementByTaskId(@Param("taskId") Long taskId);
int insertBatch(@Param("list") List<GearProductionTaskMaterial> list);
int deleteByTaskId(@Param("taskId") Long taskId);
}

View File

@@ -0,0 +1,14 @@
package com.gear.mes.production.mapper;
import com.gear.mes.production.domain.vo.GearProductionTaskProductVo;
import org.apache.ibatis.annotations.Param;
import java.util.List;
public interface GearProductionTaskProductMapper {
List<GearProductionTaskProductVo> selectByTaskId(@Param("taskId") Long taskId);
int insertBatch(@Param("list") List<com.gear.mes.production.domain.GearProductionTaskProduct> list);
int deleteByTaskId(@Param("taskId") Long taskId);
}

View File

@@ -0,0 +1,17 @@
package com.gear.mes.production.service;
import com.gear.mes.production.domain.GearProductionTask;
import com.gear.mes.production.domain.vo.GearProductionTaskListVo;
import com.gear.mes.production.domain.vo.GearProductionTaskWithDetailVo;
import java.util.List;
public interface IGearProductionTaskService {
List<GearProductionTaskListVo> selectTaskList(GearProductionTask query);
GearProductionTaskWithDetailVo selectTaskWithDetail(Long taskId);
Long insertTaskWithDetail(GearProductionTaskWithDetailVo bo);
boolean completeTask(Long taskId);
}

View File

@@ -0,0 +1,145 @@
package com.gear.mes.production.service.impl;
import cn.hutool.core.util.IdUtil;
import com.gear.mes.production.domain.GearProductionTask;
import com.gear.mes.production.domain.GearProductionTaskMaterial;
import com.gear.mes.production.domain.GearProductionTaskProduct;
import com.gear.mes.production.domain.vo.GearProductionTaskListVo;
import com.gear.mes.production.domain.vo.GearProductionTaskMaterialVo;
import com.gear.mes.production.domain.vo.GearProductionTaskWithDetailVo;
import com.gear.common.utils.DateUtils;
import com.gear.mes.production.mapper.GearProductionTaskMapper;
import com.gear.mes.production.mapper.GearProductionTaskMaterialMapper;
import com.gear.mes.production.mapper.GearProductionTaskProductMapper;
import com.gear.mes.production.service.IGearProductionTaskService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Service
public class GearProductionTaskServiceImpl implements IGearProductionTaskService {
@Autowired
private GearProductionTaskMapper taskMapper;
@Autowired
private GearProductionTaskProductMapper productMapper;
@Autowired
private GearProductionTaskMaterialMapper materialMapper;
@Override
public List<GearProductionTaskListVo> selectTaskList(GearProductionTask query) {
return taskMapper.selectTaskList(query);
}
@Override
public GearProductionTaskWithDetailVo selectTaskWithDetail(Long taskId) {
GearProductionTask task = taskMapper.selectTaskById(taskId);
GearProductionTaskWithDetailVo vo = new GearProductionTaskWithDetailVo();
vo.setTask(task);
if (taskId == null) {
vo.setProducts(null);
vo.setMaterials(null);
return vo;
}
vo.setProducts(productMapper.selectByTaskId(taskId));
List<GearProductionTaskMaterialVo> materials = materialMapper.selectByTaskId(taskId);
if (task != null && "2".equals(String.valueOf(task.getStatus())) && (materials == null || materials.isEmpty())) {
regenerateReceiptMaterials(taskId);
materials = materialMapper.selectByTaskId(taskId);
}
vo.setMaterials(materials);
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long insertTaskWithDetail(GearProductionTaskWithDetailVo bo) {
if (bo == null || bo.getTask() == null) {
return null;
}
GearProductionTask task = bo.getTask();
Long taskId = task.getTaskId() != null ? task.getTaskId() : IdUtil.getSnowflakeNextId();
task.setTaskId(taskId);
if (task.getDelFlag() == null) {
task.setDelFlag("0");
}
if (task.getStatus() == null || String.valueOf(task.getStatus()).trim().isEmpty()) {
task.setStatus("1");
}
task.setCreateTime(DateUtils.getNowDate());
task.setUpdateTime(DateUtils.getNowDate());
taskMapper.insertTask(task);
List<GearProductionTaskProduct> products = new ArrayList<>();
if (bo.getProducts() != null) {
bo.getProducts().forEach(p -> {
if (p == null || p.getProductId() == null) return;
GearProductionTaskProduct row = new GearProductionTaskProduct();
row.setLineId(IdUtil.getSnowflakeNextId());
row.setTaskId(taskId);
row.setProductId(p.getProductId());
row.setPlanQty(p.getPlanQty() == null ? BigDecimal.ZERO : p.getPlanQty());
row.setFinishedQty(p.getFinishedQty() == null ? BigDecimal.ZERO : p.getFinishedQty());
row.setBadQty(p.getBadQty() == null ? BigDecimal.ZERO : p.getBadQty());
row.setUnit(p.getUnit());
row.setRemark(p.getRemark());
products.add(row);
});
}
if (!products.isEmpty()) {
productMapper.insertBatch(products);
}
return taskId;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean completeTask(Long taskId) {
if (taskId == null) {
return false;
}
GearProductionTask task = taskMapper.selectTaskById(taskId);
if (task == null) {
return false;
}
int updated = taskMapper.updateTaskStatus(taskId, "2", task.getUpdateBy(), DateUtils.getNowDate());
if (updated <= 0) {
return false;
}
regenerateReceiptMaterials(taskId);
return true;
}
private void regenerateReceiptMaterials(Long taskId) {
materialMapper.deleteByTaskId(taskId);
List<GearProductionTaskMaterial> required = materialMapper.selectRequirementByTaskId(taskId);
if (required == null || required.isEmpty()) {
return;
}
List<GearProductionTaskMaterial> rows = new ArrayList<>();
required.forEach(r -> {
if (r == null || r.getMaterialId() == null) return;
GearProductionTaskMaterial row = new GearProductionTaskMaterial();
row.setLineId(IdUtil.getSnowflakeNextId());
row.setTaskId(taskId);
row.setMaterialId(r.getMaterialId());
row.setMaterialRole(r.getMaterialRole());
BigDecimal planQty = r.getPlanQty() == null ? BigDecimal.ZERO : r.getPlanQty();
row.setPlanQty(planQty);
row.setUsedQty(planQty);
row.setUnit(r.getUnit());
row.setRemark(null);
rows.add(row);
});
if (!rows.isEmpty()) {
materialMapper.insertBatch(rows);
}
}
}

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gear.mes.production.mapper.GearProductionTaskMapper">
<select id="selectTaskList" parameterType="com.gear.mes.production.domain.GearProductionTask" resultType="com.gear.mes.production.domain.vo.GearProductionTaskListVo">
SELECT
t.task_id AS taskId,
t.task_code AS taskCode,
t.task_name AS taskName,
t.status AS status,
t.plan_start_time AS planStartTime,
t.plan_end_time AS planEndTime,
t.remark AS remark,
t.create_time AS createTime,
t.update_time AS updateTime,
IFNULL(SUM(p.plan_qty), 0) AS planQty,
IFNULL(SUM(p.finished_qty), 0) AS finishedQty,
IFNULL(SUM(p.bad_qty), 0) AS badQty,
GREATEST(IFNULL(SUM(p.plan_qty), 0) - IFNULL(SUM(p.finished_qty), 0), 0) AS unfinishedQty
FROM gear_production_task t
LEFT JOIN gear_production_task_product p ON t.task_id = p.task_id
<where>
t.del_flag = '0'
<if test="status != null and status != ''"> AND t.status = #{status}</if>
<if test="taskCode != null and taskCode != ''"> AND t.task_code LIKE CONCAT('%', #{taskCode}, '%')</if>
<if test="taskName != null and taskName != ''"> AND t.task_name LIKE CONCAT('%', #{taskName}, '%')</if>
<if test="beginTime != null"> AND t.plan_start_time <![CDATA[>=]]> #{beginTime}</if>
<if test="endTime != null"> AND t.plan_start_time <![CDATA[<=]]> #{endTime}</if>
</where>
GROUP BY t.task_id
ORDER BY t.update_time DESC, t.create_time DESC
</select>
<select id="selectTaskById" resultType="com.gear.mes.production.domain.GearProductionTask">
SELECT
task_id AS taskId,
task_code AS taskCode,
task_name AS taskName,
status AS status,
plan_start_time AS planStartTime,
plan_end_time AS planEndTime,
remark AS remark,
create_by AS createBy,
create_time AS createTime,
update_by AS updateBy,
update_time AS updateTime
FROM gear_production_task
WHERE task_id = #{taskId}
AND del_flag = '0'
LIMIT 1
</select>
<insert id="insertTask" parameterType="com.gear.mes.production.domain.GearProductionTask">
INSERT INTO gear_production_task (
task_id,
task_code,
task_name,
status,
plan_start_time,
plan_end_time,
remark,
del_flag,
create_by,
create_time,
update_by,
update_time
) VALUES (
#{taskId},
#{taskCode},
#{taskName},
#{status},
#{planStartTime},
#{planEndTime},
#{remark},
#{delFlag},
#{createBy},
#{createTime},
#{updateBy},
#{updateTime}
)
</insert>
<update id="updateTask" parameterType="com.gear.mes.production.domain.GearProductionTask">
UPDATE gear_production_task
SET
task_code = #{taskCode},
task_name = #{taskName},
status = #{status},
plan_start_time = #{planStartTime},
plan_end_time = #{planEndTime},
remark = #{remark},
update_by = #{updateBy},
update_time = #{updateTime}
WHERE task_id = #{taskId}
AND del_flag = '0'
</update>
<update id="updateTaskStatus">
UPDATE gear_production_task
SET
status = #{status},
update_by = #{updateBy},
update_time = #{updateTime}
WHERE task_id = #{taskId}
AND del_flag = '0'
</update>
</mapper>

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gear.mes.production.mapper.GearProductionTaskMaterialMapper">
<select id="selectByTaskId" resultType="com.gear.mes.production.domain.vo.GearProductionTaskMaterialVo">
SELECT
m.line_id AS lineId,
m.task_id AS taskId,
m.material_id AS materialId,
m.material_role AS materialRole,
m.plan_qty AS planQty,
m.used_qty AS usedQty,
m.unit AS unit,
m.remark AS remark,
mm.material_name AS materialName,
mm.material_type AS materialType,
mm.spec AS spec,
mm.model AS model,
mm.factory AS factory
FROM gear_production_task_material m
LEFT JOIN mat_material mm ON m.material_id = mm.material_id
WHERE m.task_id = #{taskId}
ORDER BY m.material_role, m.line_id
</select>
<select id="selectRequirementByTaskId" resultType="com.gear.mes.production.domain.GearProductionTaskMaterial">
SELECT
r.material_id AS materialId,
CASE WHEN mm.material_type = 2 THEN 'main' ELSE 'aux' END AS materialRole,
SUM(
(
CASE
WHEN p.finished_qty IS NOT NULL AND p.finished_qty > 0 THEN p.finished_qty
ELSE IFNULL(p.plan_qty, 0)
END
) * IFNULL(r.material_num, 0)
) AS planQty,
mm.unit AS unit
FROM gear_production_task_product p
JOIN mat_product_material_relation r ON p.product_id = r.product_id AND r.del_flag = 0
LEFT JOIN mat_material mm ON r.material_id = mm.material_id
WHERE p.task_id = #{taskId}
GROUP BY r.material_id, mm.material_type, mm.unit
ORDER BY mm.material_type DESC, MIN(IFNULL(r.sort, 0)), r.material_id
</select>
<insert id="insertBatch">
INSERT INTO gear_production_task_material (
line_id,
task_id,
material_id,
material_role,
plan_qty,
used_qty,
unit,
remark
)
VALUES
<foreach collection="list" item="i" separator=",">
(
#{i.lineId},
#{i.taskId},
#{i.materialId},
#{i.materialRole},
#{i.planQty},
#{i.usedQty},
#{i.unit},
#{i.remark}
)
</foreach>
</insert>
<delete id="deleteByTaskId">
DELETE FROM gear_production_task_material WHERE task_id = #{taskId}
</delete>
</mapper>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gear.mes.production.mapper.GearProductionTaskProductMapper">
<select id="selectByTaskId" resultType="com.gear.mes.production.domain.vo.GearProductionTaskProductVo">
SELECT
p.line_id AS lineId,
p.task_id AS taskId,
p.product_id AS productId,
p.plan_qty AS planQty,
p.finished_qty AS finishedQty,
p.bad_qty AS badQty,
p.unit AS unit,
p.remark AS remark,
COALESCE(gp.product_name, mp.product_name) AS productName,
COALESCE(gp.product_code, CAST(mp.product_id AS CHAR)) AS productCode,
COALESCE(gp.type, mp.product_type) AS productType,
mp.spec AS spec,
mp.model AS model
FROM gear_production_task_product p
LEFT JOIN gear_product gp ON p.product_id = gp.product_id
LEFT JOIN mat_product mp ON p.product_id = mp.product_id
WHERE p.task_id = #{taskId}
ORDER BY p.line_id
</select>
<insert id="insertBatch">
INSERT INTO gear_production_task_product (
line_id,
task_id,
product_id,
plan_qty,
finished_qty,
bad_qty,
unit,
remark
)
VALUES
<foreach collection="list" item="i" separator=",">
(
#{i.lineId},
#{i.taskId},
#{i.productId},
#{i.planQty},
#{i.finishedQty},
#{i.badQty},
#{i.unit},
#{i.remark}
)
</foreach>
</insert>
<delete id="deleteByTaskId">
DELETE FROM gear_production_task_product WHERE task_id = #{taskId}
</delete>
</mapper>

View File

@@ -0,0 +1,31 @@
import request from '@/utils/request'
export function listProductionTask(query) {
return request({
url: '/mes/production/task/list',
method: 'get',
params: query
})
}
export function getProductionTask(taskId) {
return request({
url: '/mes/production/task/' + taskId,
method: 'get'
})
}
export function addProductionTask(data) {
return request({
url: '/mes/production/task',
method: 'post',
data
})
}
export function completeProductionTask(taskId) {
return request({
url: '/mes/production/task/' + taskId + '/complete',
method: 'post'
})
}

View File

@@ -96,6 +96,19 @@ export const constantRoutes = [
}
]
},
{
path: '/mes',
component: Layout,
hidden: true,
children: [
{
path: 'production',
component: () => import('@/views/mes/production/index.vue'),
name: 'Production',
meta: { title: '生产', icon: 'list', noCache: true }
}
]
},
{ path: '/user', component: Layout, hidden: true, redirect: 'noredirect', children: [ { path: 'profile/:activeTab?', component: () => import('@/views/system/user/profile/index'), name: 'Profile', meta: { title: '个人中心', icon: 'user' } } ] }, { path: '/mat/product', component: Layout, hidden: true, children: [ { path: 'detail/:id(\\d+)', component: () => import('@/views/mat/product/detail'), name: 'ProductDetail', meta: { title: '产品详情', activeMenu: '/mat/product' } } ] }
]

View File

@@ -91,7 +91,7 @@
</el-form-item>
<el-form-item label="物料类型" prop="materialType">
<el-select v-model="form.materialType" placeholder="请选择物料类型" disabled>
<el-option label="辅料" value="1" />
<el-option label="辅料" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="厂家" prop="factory">

View File

@@ -54,7 +54,7 @@
<el-table-column label="型号" align="center" prop="model" min-width="140" show-overflow-tooltip />
<el-table-column label="物料类型" align="center" prop="materialType">
<template #default="scope">
{{ scope.row.materialType === 1 ? '辅料' : '主材' }}
{{ scope.row.materialType === 1 ? '辅料' : '原料' }}
</template>
</el-table-column>
<el-table-column label="厂家" align="center" prop="factory" />
@@ -95,7 +95,7 @@
</el-form-item>
<el-form-item label="物料类型" prop="materialType">
<el-select v-model="form.materialType" placeholder="请选择物料类型" disabled>
<el-option label="主材" value="2" />
<el-option label="原料" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="厂家" prop="factory">

View File

@@ -0,0 +1,782 @@
<template>
<div class="production-page app-container" v-loading="loading">
<el-card shadow="never" class="page-head">
<div class="page-head__row">
<div class="page-title">
<div class="page-title__main">生产任务</div>
<div class="page-title__sub">任务清单 / 明细 / 回执</div>
</div>
<div class="page-actions">
<el-button type="primary" @click="openAdd">新增任务</el-button>
<el-button @click="loadTasks">刷新</el-button>
</div>
</div>
<div class="page-head__row page-head__row--date">
<div class="date-bar">
<el-radio-group v-model="datePreset" size="small" @change="applyDatePreset">
<el-radio-button label="today">今天</el-radio-button>
<el-radio-button label="yesterday">昨天</el-radio-button>
<el-radio-button label="last7">近7天</el-radio-button>
<el-radio-button label="thisMonth">本月</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
</el-radio-group>
<el-popover
v-if="datePreset === 'custom'"
v-model:visible="customPopoverOpen"
placement="bottom-start"
trigger="click"
width="360"
>
<template #reference>
<el-button size="small" plain class="custom-range-btn">{{ customRangeLabel }}</el-button>
</template>
<el-date-picker
v-model="customRange"
type="daterange"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
unlink-panels
teleported
style="width: 320px;"
@change="onCustomRangePicked"
/>
</el-popover>
</div>
</div>
<div class="page-head__row page-head__row--filters">
<el-form :inline="true" class="filter-form">
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 140px;">
<el-option label="进行中" value="1" />
<el-option label="已完成" value="2" />
<el-option label="已暂停" value="3" />
</el-select>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="filters.keyword" placeholder="任务名称/编号" clearable style="width: 240px;" />
</el-form-item>
</el-form>
<div class="page-stats">
<el-tag type="info" effect="light">全部 {{ taskList.length }}</el-tag>
<el-tag type="success" effect="light" style="margin-left: 8px;">进行中 {{ runningCount }}</el-tag>
<el-tag type="warning" effect="light" style="margin-left: 8px;">暂停 {{ pausedCount }}</el-tag>
<el-tag type="danger" effect="light" style="margin-left: 8px;">完成 {{ finishedCount }}</el-tag>
</div>
</div>
</el-card>
<el-row :gutter="12">
<el-col :span="6">
<el-card shadow="never" class="left-card">
<template #header>
<div class="card-header">
<span>任务清单</span>
<div>
<el-button type="primary" link @click="openAdd">新增</el-button>
<el-button type="primary" link @click="loadTasks">刷新</el-button>
</div>
</div>
</template>
<div class="task-list">
<el-empty v-if="!filteredTaskList.length" description="暂无任务" />
<el-scrollbar v-else height="640px">
<div
v-for="t in filteredTaskList"
:key="t.taskId"
class="task-item"
:class="{ active: t.taskId === selectedTaskId }"
@click="selectTask(t.taskId)"
>
<div class="task-item__top">
<div class="task-item__title">{{ t.taskName || t.taskCode || ('任务' + t.taskId) }}</div>
<el-tag size="small" :type="statusTagType(t.status)" effect="light">{{ statusLabel(t.status) }}</el-tag>
</div>
<div class="task-item__sub">
<span>计划 {{ formatQty(t.planQty) }}</span>
<span style="margin-left: 10px;">已完 {{ formatQty(t.finishedQty) }}</span>
<span style="margin-left: 10px;">未完 {{ formatQty(t.unfinishedQty) }}</span>
</div>
<el-progress :percentage="taskProgressPercent(t)" :stroke-width="8" :show-text="false" />
</div>
</el-scrollbar>
</div>
</el-card>
</el-col>
<el-col :span="18">
<el-card shadow="never" class="mb12">
<template #header>
<div class="card-header">
<span>任务明细</span>
<div v-if="selectedTask" class="card-header__right">
<div class="card-meta">
<span class="meta-main">{{ selectedTask.taskName || selectedTask.taskCode || ('任务' + selectedTask.taskId) }}</span>
<el-tag size="small" :type="statusTagType(selectedTask.status)" effect="light" style="margin-left: 8px;">{{ statusLabel(selectedTask.status) }}</el-tag>
</div>
<el-button
v-if="String(selectedTask.status) === '1'"
size="small"
type="primary"
:loading="completeLoading"
@click="completeSelectedTask"
>
完成任务
</el-button>
</div>
</div>
</template>
<el-empty v-if="!selectedTaskId" description="请选择任务" />
<el-table v-else :data="productRows" size="small" border stripe :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column label="产品" prop="productName" min-width="180" show-overflow-tooltip />
<el-table-column label="规格" prop="spec" width="140" show-overflow-tooltip />
<el-table-column label="型号" prop="model" width="140" show-overflow-tooltip />
<el-table-column label="计划数量" prop="planQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.planQty) }}</template>
</el-table-column>
<el-table-column label="已完成" prop="finishedQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.finishedQty) }}</template>
</el-table-column>
<el-table-column label="不良" prop="badQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.badQty) }}</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="90" />
</el-table>
</el-card>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>任务回执</span>
<div v-if="selectedTask" class="card-meta">
<span class="meta-sub">主材 / 辅材 / 产品概况</span>
</div>
</div>
</template>
<el-empty v-if="!selectedTaskId" description="请选择任务" />
<el-tabs v-else type="border-card">
<el-tab-pane label="主材">
<el-table :data="mainMaterialRows" size="small" border stripe :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column label="名称" prop="materialName" min-width="180" show-overflow-tooltip />
<el-table-column label="计划" prop="planQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.planQty) }}</template>
</el-table-column>
<el-table-column label="已用" prop="usedQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.usedQty) }}</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="90" />
</el-table>
</el-tab-pane>
<el-tab-pane label="辅材">
<el-table :data="auxMaterialRows" size="small" border stripe :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column label="名称" prop="materialName" min-width="180" show-overflow-tooltip />
<el-table-column label="计划" prop="planQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.planQty) }}</template>
</el-table-column>
<el-table-column label="已用" prop="usedQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.usedQty) }}</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="90" />
</el-table>
</el-tab-pane>
<el-tab-pane label="产品概况">
<el-table :data="productRows" size="small" border stripe :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column label="产品" prop="productName" min-width="180" show-overflow-tooltip />
<el-table-column label="规格/型号" min-width="200">
<template #default="scope">
{{ (scope.row.spec || '-') + ' / ' + (scope.row.model || '-') }}
</template>
</el-table-column>
<el-table-column label="计划" prop="planQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.planQty) }}</template>
</el-table-column>
<el-table-column label="已完成" prop="finishedQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.finishedQty) }}</template>
</el-table-column>
<el-table-column label="不良" prop="badQty" width="120" align="right">
<template #default="scope">{{ formatQty(scope.row.badQty) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
<el-dialog v-model="addOpen" title="新增生产任务" width="1100px" top="5vh" append-to-body>
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="100px">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="任务编号" prop="taskCode">
<el-input v-model="addForm.taskCode" placeholder="可不填" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="任务名称" prop="taskName">
<el-input v-model="addForm.taskName" placeholder="请输入任务名称" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="状态" prop="status">
<el-select v-model="addForm.status" placeholder="请选择">
<el-option label="进行中" value="1" />
<el-option label="已完成" value="2" />
<el-option label="已暂停" value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="计划开始" prop="planStartTime">
<el-date-picker v-model="addForm.planStartTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="计划结束" prop="planEndTime">
<el-date-picker v-model="addForm.planEndTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss" style="width: 100%;" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">生产产品明细</el-divider>
<div style="margin-bottom: 8px;">
<el-button type="primary" @click="addProductLine">新增产品行</el-button>
</div>
<el-table :data="addProducts" border size="small" :header-cell-style="{ background: '#f5f7fa' }">
<el-table-column label="产品" min-width="220">
<template #default="scope">
<el-select v-model="scope.row.productId" filterable clearable placeholder="请选择" style="width: 100%;" @change="onProductPicked(scope.row)">
<el-option v-for="p in productOptions" :key="p.productId" :label="p.productName" :value="p.productId" />
</el-select>
</template>
</el-table-column>
<el-table-column label="计划数量" width="140" align="right">
<template #default="scope">
<el-input-number v-model="scope.row.planQty" :min="0" :precision="4" controls-position="right" style="width: 120px;" />
</template>
</el-table-column>
<el-table-column label="单位" width="140">
<template #default="scope">
<el-input v-model="scope.row.unit" placeholder="单位" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="200">
<template #default="scope">
<el-input v-model="scope.row.remark" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center">
<template #default="scope">
<el-button type="danger" link @click="removeProductLine(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="addOpen = false">取消</el-button>
<el-button type="primary" :loading="addSaving" @click="submitAdd">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="Production">
import { computed, onMounted, ref } from 'vue'
import { addProductionTask, completeProductionTask, getProductionTask, listProductionTask } from '@/api/mes/productionTask'
import { listProductBase } from '@/api/mat/product'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const taskList = ref([])
const selectedTaskId = ref(null)
const productRows = ref([])
const materialRows = ref([])
const datePreset = ref('today')
const customRange = ref([])
const customPopoverOpen = ref(false)
const filters = ref({
status: '1',
keyword: '',
beginTime: '',
endTime: ''
})
const addOpen = ref(false)
const addSaving = ref(false)
const completeLoading = ref(false)
const addFormRef = ref()
const addForm = ref({
taskCode: '',
taskName: '',
status: '1',
planStartTime: '',
planEndTime: '',
remark: ''
})
const addRules = {
taskName: [{ required: true, message: '任务名称不能为空', trigger: 'blur' }]
}
const productOptions = ref([])
const addProducts = ref([])
function toNumber(v) {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
function formatQty(v) {
return toNumber(v).toFixed(2)
}
function normalizeRole(v) {
return String(v || '').toLowerCase()
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function formatDateTime(dt) {
const d = dt instanceof Date ? dt : new Date(dt)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
}
function startOfDay(d) {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
function endOfDay(d) {
const x = new Date(d)
x.setHours(23, 59, 59, 999)
return x
}
function addDays(d, n) {
const x = new Date(d)
x.setDate(x.getDate() + n)
return x
}
function startOfMonth(d) {
const x = new Date(d)
x.setDate(1)
x.setHours(0, 0, 0, 0)
return x
}
function applyDatePreset() {
const now = new Date()
let begin = null
let end = null
const p = String(datePreset.value || '')
if (p === 'today') {
begin = startOfDay(now)
end = endOfDay(now)
} else if (p === 'yesterday') {
const y = addDays(now, -1)
begin = startOfDay(y)
end = endOfDay(y)
} else if (p === 'last7') {
begin = startOfDay(addDays(now, -6))
end = endOfDay(now)
} else if (p === 'thisMonth') {
begin = startOfMonth(now)
end = endOfDay(now)
} else if (p === 'custom') {
if (!customRange.value || customRange.value.length !== 2) {
const b = startOfDay(now)
const e = endOfDay(now)
customRange.value = [formatDateTime(b).slice(0, 10), formatDateTime(e).slice(0, 10)]
filters.value.beginTime = formatDateTime(b)
filters.value.endTime = formatDateTime(e)
loadTasks()
}
customPopoverOpen.value = true
return
}
if (begin && end) {
filters.value.beginTime = formatDateTime(begin)
filters.value.endTime = formatDateTime(end)
loadTasks()
}
}
function applyCustomRange(v) {
if (!v || v.length !== 2) return
datePreset.value = 'custom'
const begin = startOfDay(new Date(v[0]))
const end = endOfDay(new Date(v[1]))
filters.value.beginTime = formatDateTime(begin)
filters.value.endTime = formatDateTime(end)
loadTasks()
}
const customRangeLabel = computed(() => {
const v = customRange.value
if (Array.isArray(v) && v.length === 2 && v[0] && v[1]) {
return `${v[0]} ~ ${v[1]}`
}
return '选择日期范围'
})
function onCustomRangePicked(v) {
applyCustomRange(v)
customPopoverOpen.value = false
}
function statusLabel(v) {
const s = String(v || '')
if (s === '1') return '进行中'
if (s === '2') return '已完成'
if (s === '3') return '已暂停'
return s || '-'
}
function statusTagType(v) {
const s = String(v || '')
if (s === '1') return 'success'
if (s === '2') return 'danger'
if (s === '3') return 'warning'
return 'info'
}
function taskProgressPercent(t) {
const plan = toNumber(t && t.planQty)
const finished = toNumber(t && t.finishedQty)
if (plan <= 0) return 0
const p = Math.round((finished / plan) * 100)
return Math.max(0, Math.min(100, p))
}
function selectTask(taskId) {
selectedTaskId.value = taskId
loading.value = true
return getProductionTask(taskId)
.then((res) => {
const data = res && res.data ? res.data : {}
productRows.value = Array.isArray(data.products) ? data.products : []
materialRows.value = Array.isArray(data.materials) ? data.materials : []
})
.finally(() => {
loading.value = false
})
}
const mainMaterialRows = computed(() => (materialRows.value || []).filter((r) => normalizeRole(r && r.materialRole) === 'main'))
const auxMaterialRows = computed(() => (materialRows.value || []).filter((r) => normalizeRole(r && r.materialRole) === 'aux'))
const selectedTask = computed(() => (taskList.value || []).find((t) => String(t.taskId) === String(selectedTaskId.value)) || null)
const runningCount = computed(() => (taskList.value || []).filter((t) => String(t && t.status) === '1').length)
const finishedCount = computed(() => (taskList.value || []).filter((t) => String(t && t.status) === '2').length)
const pausedCount = computed(() => (taskList.value || []).filter((t) => String(t && t.status) === '3').length)
const filteredTaskList = computed(() => {
const list = Array.isArray(taskList.value) ? taskList.value : []
const st = String(filters.value && filters.value.status ? filters.value.status : '')
const kw = String(filters.value && filters.value.keyword ? filters.value.keyword : '').trim().toLowerCase()
return list.filter((t) => {
if (st && String(t && t.status) !== st) return false
if (kw) {
const a = String(t && t.taskName ? t.taskName : '').toLowerCase()
const b = String(t && t.taskCode ? t.taskCode : '').toLowerCase()
if (!a.includes(kw) && !b.includes(kw)) return false
}
return true
})
})
function loadTasks() {
loading.value = true
const q = {
pageNum: 1,
pageSize: 9999,
status: filters.value && filters.value.status ? filters.value.status : undefined,
beginTime: filters.value && filters.value.beginTime ? filters.value.beginTime : undefined,
endTime: filters.value && filters.value.endTime ? filters.value.endTime : undefined
}
return listProductionTask(q)
.then((res) => {
const rows = (res && res.rows) ? res.rows : []
taskList.value = rows
if (!selectedTaskId.value && taskList.value.length) {
const first = filteredTaskList.value && filteredTaskList.value.length ? filteredTaskList.value[0] : taskList.value[0]
return selectTask(first.taskId)
}
if (selectedTaskId.value) {
const hit = (taskList.value || []).some((t) => t.taskId === selectedTaskId.value)
if (hit) return selectTask(selectedTaskId.value)
}
})
.finally(() => {
loading.value = false
})
}
function openAdd() {
addOpen.value = true
addForm.value = {
taskCode: '',
taskName: '',
status: '1',
planStartTime: '',
planEndTime: '',
remark: ''
}
addProducts.value = []
addProductLine()
ensureProductOptions()
}
function ensureProductOptions() {
if (productOptions.value && productOptions.value.length) return
listProductBase({ pageNum: 1, pageSize: 1000 }).then((res) => {
productOptions.value = (res && res.rows) ? res.rows : []
})
}
function addProductLine() {
addProducts.value.push({ productId: null, planQty: 0, unit: '', remark: '' })
}
function removeProductLine(idx) {
addProducts.value.splice(idx, 1)
}
function onProductPicked(row) {
if (!row || !row.productId) return
const hit = (productOptions.value || []).find((p) => String(p.productId) === String(row.productId))
if (hit && !row.unit) {
row.unit = ''
}
}
function submitAdd() {
if (!addFormRef.value) return
addFormRef.value.validate((valid) => {
if (!valid) return
const task = Object.assign({}, addForm.value)
const products = (addProducts.value || [])
.filter((p) => p && p.productId != null)
.map((p) => ({
productId: p.productId,
planQty: p.planQty || 0,
finishedQty: 0,
badQty: 0,
unit: p.unit || '',
remark: p.remark || ''
}))
addSaving.value = true
addProductionTask({ task, products })
.then((res) => {
const taskId = res && res.data ? res.data : null
ElMessage.success('已新增')
addOpen.value = false
return loadTasks().then(() => {
if (taskId != null) {
selectedTaskId.value = taskId
return selectTask(taskId)
}
})
})
.finally(() => {
addSaving.value = false
})
})
}
function completeSelectedTask() {
const task = selectedTask.value
const taskId = selectedTaskId.value
if (!task || taskId == null) return
completeLoading.value = true
return ElMessageBox.confirm('完成后将根据产品配方自动生成主材/辅材回执,是否继续?', '确认完成', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => completeProductionTask(taskId))
.then(() => {
ElMessage.success('已完成')
return loadTasks().then(() => selectTask(taskId))
})
.catch((e) => {
const s = String(e || '')
if (s && s !== 'cancel' && s !== 'close') {
ElMessage.error('操作失败')
}
})
.finally(() => {
completeLoading.value = false
})
}
onMounted(() => {
applyDatePreset()
})
</script>
<style scoped>
.production-page {
padding: 0;
}
.production-page :deep(.el-card) {
border-radius: 10px;
}
.production-page :deep(.el-card__header) {
background: #fafafa;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header__right {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.card-meta {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
.meta-main {
max-width: 520px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
font-weight: 700;
}
.meta-sub {
color: #909399;
font-size: 12px;
}
.page-head {
margin-bottom: 12px;
}
.page-head__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-head__row--filters {
margin-top: 10px;
}
.page-head__row--date {
margin-top: 10px;
}
.date-bar {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
flex-wrap: wrap;
}
.custom-range-btn {
min-width: 180px;
justify-content: flex-start;
}
.page-title__main {
font-size: 18px;
font-weight: 800;
color: #303133;
}
.page-title__sub {
margin-top: 2px;
font-size: 12px;
color: #909399;
}
.page-actions {
display: flex;
gap: 8px;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
}
.page-stats {
display: flex;
align-items: center;
justify-content: flex-end;
}
.task-item {
padding: 10px 10px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
background: #fff;
transition: all 0.15s ease;
}
.task-item:hover {
border-color: rgba(64, 158, 255, 0.55);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
.task-item.active {
border-color: #409eff;
background: rgba(64, 158, 255, 0.08);
}
.task-item__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.task-item__title {
font-size: 14px;
font-weight: 700;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-item__sub {
margin-top: 6px;
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.mb12 {
margin-bottom: 12px;
}
</style>