新增生产-前缫看台记录,并导入车头工/挡车工看台记录历史数据

功能:
- 后端新增 KtjlCtgs / KtjlDcgs 两张表、KtjlService 与 api/prod/ktjl 接口
  (按日期+车间+班次查询、整组先删后建保存、整组删除、按看台排班带出默认记录、工种下拉)
- 挡车数按 F9 口径自动重算(与他人接壤的边界车位各扣 0.5),机组由车位反查机组车台
- 前端新增 /prod/qianxian/ktjl 看台记录页:挡车工/车头工双页签、删除、录入(新增)、录入(指定日期)
- 录入弹窗校验对齐 F9:个人时长不超标准、挡车数/看台时长匹配、车头工每机组≥2 人且时长为标准时长整数倍
- 生产-前缫菜单新增「看台记录」,权限 prod:ktjl:list

数据导入:
- 新增内嵌资源 biz_prod_qs_ktjl_ctg.csv / biz_prod_qs_ktjl_dcg.csv(约 5 万行,采用紧凑 CSV)
- 剔除软删除行、日期异常行、部门无法映射与职工不存在的行;日期由 Excel 序列号转换
- 车头工 12693 行、挡车工 38048 行入库,日期范围 2025-12-16 ~ 2026-09-11
- 表内已有数据则整体跳过,分批 1000 行插入

其他:同步工作进度-13 文档与开发数据库快照
This commit is contained in:
yanpeng200212
2026-10-22 11:16:29 +08:00
parent 2e4c045818
commit 69c4052a64
15 changed files with 52228 additions and 2 deletions
@@ -0,0 +1,108 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos.Prod;
using F9WebAiTest.Application.Services.Prod;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Enums.SYS.Employees;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace F9WebAiTest.Api.Controllers.Prod;
/// <summary>看台记录(生产-前缫):车头工 / 挡车工</summary>
[ApiController]
[Route("api/prod/ktjl")]
[Authorize]
public class KtjlController : ControllerBase
{
private readonly IKtjlService _svc;
public KtjlController(IKtjlService svc) => _svc = svc;
// ---------- 查询 ----------
/// <summary>挡车工看台记录(某日某车间某班次)</summary>
[HttpGet("dcg")]
public async Task<IActionResult> GetDcg([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲, [FromQuery] string like = "")
=> Ok(ApiResult<List<KtjldcgReadDto_View>>.Ok(
await _svc.GetDcgAsync(date, depId, banci, like ?? string.Empty)));
/// <summary>车头工看台记录</summary>
[HttpGet("ctg")]
public async Task<IActionResult> GetCtg([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲, [FromQuery] string like = "")
=> Ok(ApiResult<List<KtjlctgReadDto_View>>.Ok(
await _svc.GetCtgAsync(date, depId, banci, like ?? string.Empty)));
// ---------- 录入(新增):按看台排班带出默认记录 ----------
[HttpGet("dcg/defaults")]
public async Task<IActionResult> GetDcgDefaults([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲)
{
try { return Ok(ApiResult<List<KtjldcgReadDto_View>>.Ok(await _svc.BuildDcgDefaultsAsync(date, depId, banci))); }
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
[HttpGet("ctg/defaults")]
public async Task<IActionResult> GetCtgDefaults([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲)
{
try { return Ok(ApiResult<List<KtjlctgReadDto_View>>.Ok(await _svc.BuildCtgDefaultsAsync(date, depId, banci))); }
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
// ---------- 整组保存 ----------
[HttpPost("dcg/batch")]
public async Task<IActionResult> SaveDcg([FromBody] KtjldcgBatchDto dto)
{
try
{
var count = await _svc.SaveDcgAsync(dto);
return Ok(ApiResult<object>.Ok(count, count > 0 ? $"已保存 {count} 条看台记录" : "已清空该班次看台记录"));
}
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
[HttpPost("ctg/batch")]
public async Task<IActionResult> SaveCtg([FromBody] KtjlctgBatchDto dto)
{
try
{
var count = await _svc.SaveCtgAsync(dto);
return Ok(ApiResult<object>.Ok(count, count > 0 ? $"已保存 {count} 条看台记录" : "已清空该班次看台记录"));
}
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
// ---------- 整组删除 ----------
[HttpDelete("dcg")]
public async Task<IActionResult> DeleteDcg([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲)
{
try
{
await _svc.DeleteDcgAsync(date, depId, banci);
return Ok(ApiResult<object>.Ok(null, "删除成功"));
}
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
[HttpDelete("ctg")]
public async Task<IActionResult> DeleteCtg([FromQuery] DateTime date, [FromQuery] long depId,
[FromQuery] Prod_Banci_Enum banci = Prod_Banci_Enum.甲)
{
try
{
await _svc.DeleteCtgAsync(date, depId, banci);
return Ok(ApiResult<object>.Ok(null, "删除成功"));
}
catch (ServiceException ex) { return StatusCode(ex.Code, ApiResult<object>.Fail(ex.Message, ex.Code)); }
}
/// <summary>工种下拉</summary>
[HttpGet("worktypes")]
public async Task<IActionResult> GetWorktypes()
=> Ok(ApiResult<List<ProdWorktypeOptionDto>>.Ok(await _svc.GetWorktypeOptionsAsync()));
}
Binary file not shown.
@@ -28,6 +28,7 @@ public static class DependencyInjection
services.AddScoped<IHbjyService, HbjyService>(); services.AddScoped<IHbjyService, HbjyService>();
services.AddScoped<IChejianBanciService, ChejianBanciService>(); services.AddScoped<IChejianBanciService, ChejianBanciService>();
services.AddScoped<IKtpbService, KtpbService>(); services.AddScoped<IKtpbService, KtpbService>();
services.AddScoped<IKtjlService, KtjlService>();
services.AddScoped<INotificationService, NotificationService>(); services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<IReportService, ReportService>(); services.AddScoped<IReportService, ReportService>();
@@ -0,0 +1,67 @@
using F9WebAiTest.Domain.Enums.SYS.Employees;
namespace F9WebAiTest.Application.Dtos.Prod;
// ==================== 车头工看台记录 ====================
public class KtjlctgReadDto_View
{
public long Id { get; set; }
public DateTime Date { get; set; }
public long DepId { get; set; }
public string DepName { get; set; } = "";
public Prod_Banci_Enum Banci { get; set; }
public string BanciName { get; set; } = "";
public int Jizu { get; set; }
public decimal Hours { get; set; }
public long EmployeeId { get; set; }
public string EmployeeName { get; set; } = "";
public long EmployeeDepId { get; set; }
public string EmployeeDepName { get; set; } = "";
public long WorktypeId { get; set; }
public string WorktypeName { get; set; } = "";
public bool NoChanliang { get; set; }
}
// ==================== 挡车工看台记录 ====================
public class KtjldcgReadDto_View
{
public long Id { get; set; }
public DateTime Date { get; set; }
public long DepId { get; set; }
public string DepName { get; set; } = "";
public Prod_Banci_Enum Banci { get; set; }
public string BanciName { get; set; } = "";
public int Jizu { get; set; }
public int ChetaiStart { get; set; }
public int ChetaiEnd { get; set; }
public decimal DangcheNum { get; set; }
public decimal Hours { get; set; }
public long EmployeeId { get; set; }
public string EmployeeName { get; set; } = "";
public long EmployeeDepId { get; set; }
public string EmployeeDepName { get; set; } = "";
public long WorktypeId { get; set; }
public string WorktypeName { get; set; } = "";
public bool NoChanliang { get; set; }
}
// ==================== 整组保存 ====================
/// <summary>看台记录整组保存(按 日期+车间+班次 先删后建,对齐 F9 的 SaveCommand)</summary>
public class KtjlctgBatchDto
{
public DateTime Date { get; set; }
public long DepId { get; set; }
public Prod_Banci_Enum Banci { get; set; }
public List<KtjlctgReadDto_View> Items { get; set; } = new();
}
public class KtjldcgBatchDto
{
public DateTime Date { get; set; }
public long DepId { get; set; }
public Prod_Banci_Enum Banci { get; set; }
public List<KtjldcgReadDto_View> Items { get; set; } = new();
}
@@ -0,0 +1,378 @@
using F9WebAiTest.Application.Common;
using F9WebAiTest.Application.Dtos.Prod;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Entities.Base;
using F9WebAiTest.Domain.Entities.Prod;
using F9WebAiTest.Domain.Enums.SYS.Employees;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using SysEmp = F9WebAiTest.Domain.Entities.SYS.Employees;
using SysWt = F9WebAiTest.Domain.Entities.SYS.WorkTypes;
namespace F9WebAiTest.Application.Services.Prod;
/// <summary>
/// 看台记录(生产-前缫):按「日期 + 车间 + 班次」维护车头工 / 挡车工的实际看台情况。
/// 对应 F9 Page_Ktjl(列表)、Page_Ktjl_Dcg_Edit / Page_Ktjl_Ctg_Edit(录入弹窗)与
/// Ktjl_Dcg_SaveCommand / Ktjl_Ctg_SaveCommand(整组先删后建)。
/// </summary>
public interface IKtjlService
{
// 查询
Task<List<KtjlctgReadDto_View>> GetCtgAsync(DateTime date, long depId, Prod_Banci_Enum banci, string like);
Task<List<KtjldcgReadDto_View>> GetDcgAsync(DateTime date, long depId, Prod_Banci_Enum banci, string like);
// 保存 / 删除(整组)
Task<int> SaveCtgAsync(KtjlctgBatchDto dto);
Task<int> SaveDcgAsync(KtjldcgBatchDto dto);
Task DeleteCtgAsync(DateTime date, long depId, Prod_Banci_Enum banci);
Task DeleteDcgAsync(DateTime date, long depId, Prod_Banci_Enum banci);
/// <summary>录入(新增):按看台排班 + 该车间工种职工,带出默认记录</summary>
Task<List<KtjlctgReadDto_View>> BuildCtgDefaultsAsync(DateTime date, long depId, Prod_Banci_Enum banci);
Task<List<KtjldcgReadDto_View>> BuildDcgDefaultsAsync(DateTime date, long depId, Prod_Banci_Enum banci);
/// <summary>工种下拉(用于筛选/展示)</summary>
Task<List<ProdWorktypeOptionDto>> GetWorktypeOptionsAsync();
}
public class ProdWorktypeOptionDto
{
public long Id { get; set; }
public string Name { get; set; } = "";
}
public class KtjlService : IKtjlService
{
private const decimal DefaultHours = 9m;
private readonly IRepository<KtjlCtg> _ctgRepo;
private readonly IRepository<KtjlDcg> _dcgRepo;
private readonly IRepository<KtpbCtg> _ktpbCtgRepo;
private readonly IRepository<KtpbDcg> _ktpbDcgRepo;
private readonly IRepository<JizuChetai> _jzctRepo;
private readonly IRepository<SysDept.Department> _deptRepo;
private readonly IRepository<SysEmp.Employee> _empRepo;
private readonly IRepository<SysWt.WorkType> _wtRepo;
public KtjlService(IRepository<KtjlCtg> ctgRepo, IRepository<KtjlDcg> dcgRepo,
IRepository<KtpbCtg> ktpbCtgRepo, IRepository<KtpbDcg> ktpbDcgRepo,
IRepository<JizuChetai> jzctRepo, IRepository<SysDept.Department> deptRepo,
IRepository<SysEmp.Employee> empRepo, IRepository<SysWt.WorkType> wtRepo)
{
_ctgRepo = ctgRepo;
_dcgRepo = dcgRepo;
_ktpbCtgRepo = ktpbCtgRepo;
_ktpbDcgRepo = ktpbDcgRepo;
_jzctRepo = jzctRepo;
_deptRepo = deptRepo;
_empRepo = empRepo;
_wtRepo = wtRepo;
}
// ==================== 查询 ====================
public async Task<List<KtjlctgReadDto_View>> GetCtgAsync(DateTime date, long depId, Prod_Banci_Enum banci,
string like)
{
var d = date.Date;
var ctx = await BuildContextAsync();
var rows = (await _ctgRepo.FindAsync(x => x.Date == d && x.DeptId == depId && x.Banci == banci))
.OrderBy(x => x.Jizu).ThenBy(x => x.Hours).ToList();
var views = rows.Select(x => ToCtgView(x, ctx)).ToList();
return Filter(views, like, v => $"{v.EmployeeName}{v.WorktypeName}{v.EmployeeDepName}{v.Jizu}");
}
public async Task<List<KtjldcgReadDto_View>> GetDcgAsync(DateTime date, long depId, Prod_Banci_Enum banci,
string like)
{
var d = date.Date;
var ctx = await BuildContextAsync();
var rows = (await _dcgRepo.FindAsync(x => x.Date == d && x.DeptId == depId && x.Banci == banci))
.OrderBy(x => x.Jizu).ThenBy(x => x.ChetaiStart).ThenBy(x => x.Hours).ToList();
var views = rows.Select(x => ToDcgView(x, ctx)).ToList();
return Filter(views, like,
v => $"{v.EmployeeName}{v.WorktypeName}{v.EmployeeDepName}{v.Jizu}{v.ChetaiStart}{v.ChetaiEnd}");
}
private static List<T> Filter<T>(List<T> rows, string like, Func<T, string> selector)
{
if (string.IsNullOrWhiteSpace(like)) return rows;
var kw = like.Trim();
return rows.Where(x => selector(x).Contains(kw, StringComparison.OrdinalIgnoreCase)).ToList();
}
// ==================== 保存 ====================
public async Task<int> SaveCtgAsync(KtjlctgBatchDto dto)
{
if (dto.DepId <= 0) throw new ServiceException("请选择车间");
var items = dto.Items ?? new List<KtjlctgReadDto_View>();
foreach (var it in items)
{
if (it.EmployeeId <= 0) throw new ServiceException("不能有空数据(存在未选择职工的记录)");
if (it.Hours < 0) throw new ServiceException($"「{it.EmployeeName}」的时长不能为负数");
}
var d = dto.Date.Date;
await DeleteCtgCoreAsync(d, dto.DepId, dto.Banci);
foreach (var it in items)
{
await _ctgRepo.AddAsync(new KtjlCtg
{
Date = d,
DeptId = dto.DepId,
Banci = dto.Banci,
Jizu = it.Jizu,
Hours = it.Hours,
EmployeeId = it.EmployeeId,
EmployeeDepId = it.EmployeeDepId,
WorktypeId = it.WorktypeId,
WorktypeName = it.WorktypeName ?? string.Empty,
NoChanliang = it.NoChanliang
});
}
await _ctgRepo.SaveChangesAsync();
return items.Count;
}
public async Task<int> SaveDcgAsync(KtjldcgBatchDto dto)
{
if (dto.DepId <= 0) throw new ServiceException("请选择车间");
var items = dto.Items ?? new List<KtjldcgReadDto_View>();
foreach (var it in items)
{
if (it.EmployeeId <= 0) throw new ServiceException("不能有空数据(存在未选择职工的记录)");
if (it.ChetaiStart <= 0) throw new ServiceException($"「{it.EmployeeName}」请填写开始车位");
if (it.ChetaiEnd < it.ChetaiStart)
throw new ServiceException($"「{it.EmployeeName}」结束车位不能小于开始车位");
if (it.Hours < 0) throw new ServiceException($"「{it.EmployeeName}」的时长不能为负数");
}
var d = dto.Date.Date;
var jzcts = (await _jzctRepo.FindAsync(x => x.DepartmentId == dto.DepId)).OrderBy(x => x.Jizu).ToList();
// 挡车数 / 机组 由服务端重算(对齐 F9 CalDangcheNum + CalJizu)
foreach (var it in items)
{
var startShared = items.Any(x => x.EmployeeId != it.EmployeeId && x.ChetaiEnd == it.ChetaiStart);
var endShared = items.Any(x => x.EmployeeId != it.EmployeeId && x.ChetaiStart == it.ChetaiEnd);
it.DangcheNum = it.ChetaiEnd + 1 - it.ChetaiStart
- (startShared ? 0.5m : 0m) - (endShared ? 0.5m : 0m);
var jzct = jzcts.FirstOrDefault(x => x.ChetaiStart <= it.ChetaiEnd && x.ChetaiEnd >= it.ChetaiStart);
if (jzct != null) it.Jizu = jzct.Jizu;
}
await DeleteDcgCoreAsync(d, dto.DepId, dto.Banci);
foreach (var it in items)
{
await _dcgRepo.AddAsync(new KtjlDcg
{
Date = d,
DeptId = dto.DepId,
Banci = dto.Banci,
Jizu = it.Jizu,
ChetaiStart = it.ChetaiStart,
ChetaiEnd = it.ChetaiEnd,
DangcheNum = it.DangcheNum,
Hours = it.Hours,
EmployeeId = it.EmployeeId,
EmployeeDepId = it.EmployeeDepId,
WorktypeId = it.WorktypeId,
WorktypeName = it.WorktypeName ?? string.Empty,
NoChanliang = it.NoChanliang
});
}
await _dcgRepo.SaveChangesAsync();
return items.Count;
}
public async Task DeleteCtgAsync(DateTime date, long depId, Prod_Banci_Enum banci)
{
if (depId <= 0) throw new ServiceException("请选择车间");
await DeleteCtgCoreAsync(date.Date, depId, banci);
await _ctgRepo.SaveChangesAsync();
}
public async Task DeleteDcgAsync(DateTime date, long depId, Prod_Banci_Enum banci)
{
if (depId <= 0) throw new ServiceException("请选择车间");
await DeleteDcgCoreAsync(date.Date, depId, banci);
await _dcgRepo.SaveChangesAsync();
}
private async Task DeleteCtgCoreAsync(DateTime date, long depId, Prod_Banci_Enum banci)
{
var olds = await _ctgRepo.FindAsync(x => x.Date == date && x.DeptId == depId && x.Banci == banci);
if (olds.Count > 0) await _ctgRepo.HardDeleteRangeAsync(olds);
}
private async Task DeleteDcgCoreAsync(DateTime date, long depId, Prod_Banci_Enum banci)
{
var olds = await _dcgRepo.FindAsync(x => x.Date == date && x.DeptId == depId && x.Banci == banci);
if (olds.Count > 0) await _dcgRepo.HardDeleteRangeAsync(olds);
}
// ==================== 默认记录(录入(新增)) ====================
public async Task<List<KtjlctgReadDto_View>> BuildCtgDefaultsAsync(DateTime date, long depId,
Prod_Banci_Enum banci)
{
var ctx = await BuildContextAsync();
var deptName = ctx.DeptNames.GetValueOrDefault(depId, "");
var plans = (await _ktpbCtgRepo.FindAsync(x => x.DeptId == depId && x.Banci == banci)).OrderBy(x => x.Jizu);
var result = new List<KtjlctgReadDto_View>();
foreach (var p in plans)
{
foreach (var ygId in new[] { p.Ygid1, p.Ygid2 })
{
if (ygId <= 0 || !ctx.Emps.TryGetValue(ygId, out var emp)) continue;
result.Add(new KtjlctgReadDto_View
{
Date = date.Date,
DepId = depId,
DepName = deptName,
Banci = banci,
BanciName = banci.ToString(),
Jizu = p.Jizu,
Hours = DefaultHours,
EmployeeId = emp.Id,
EmployeeName = emp.Name,
EmployeeDepId = emp.DepId,
EmployeeDepName = ctx.DeptNames.GetValueOrDefault(emp.DepId, ""),
WorktypeId = emp.WorktypeId,
WorktypeName = ctx.WorktypeNames.GetValueOrDefault(emp.WorktypeId, ""),
NoChanliang = !emp.IsZhuanzheng
});
}
}
return result;
}
public async Task<List<KtjldcgReadDto_View>> BuildDcgDefaultsAsync(DateTime date, long depId,
Prod_Banci_Enum banci)
{
var ctx = await BuildContextAsync();
var deptName = ctx.DeptNames.GetValueOrDefault(depId, "");
var plans = (await _ktpbDcgRepo.FindAsync(x => x.DeptId == depId && x.Banci == banci))
.OrderBy(x => x.Jizu).ThenBy(x => x.ChetaiStart);
var result = new List<KtjldcgReadDto_View>();
foreach (var p in plans)
{
if (p.YgId <= 0 || !ctx.Emps.TryGetValue(p.YgId, out var emp)) continue;
result.Add(new KtjldcgReadDto_View
{
Date = date.Date,
DepId = depId,
DepName = deptName,
Banci = banci,
BanciName = banci.ToString(),
Jizu = p.Jizu,
ChetaiStart = p.ChetaiStart,
ChetaiEnd = p.ChetaiEnd,
DangcheNum = (decimal)p.Dangcheshu,
Hours = DefaultHours,
EmployeeId = emp.Id,
EmployeeName = emp.Name,
EmployeeDepId = emp.DepId,
EmployeeDepName = ctx.DeptNames.GetValueOrDefault(emp.DepId, ""),
WorktypeId = emp.WorktypeId,
WorktypeName = ctx.WorktypeNames.GetValueOrDefault(emp.WorktypeId, ""),
NoChanliang = !emp.IsZhuanzheng
});
}
return result;
}
public async Task<List<ProdWorktypeOptionDto>> GetWorktypeOptionsAsync()
=> (await _wtRepo.GetAllAsync()).OrderBy(x => x.SortOrder).ThenBy(x => x.Id)
.Select(x => new ProdWorktypeOptionDto { Id = x.Id, Name = x.WorkName })
.ToList();
// ==================== 上下文 ====================
private sealed class Ctx
{
public Dictionary<long, string> DeptNames { get; init; } = new();
public Dictionary<long, string> WorktypeNames { get; init; } = new();
public Dictionary<long, EmpInfo> Emps { get; init; } = new();
}
private sealed class EmpInfo
{
public long Id { get; init; }
public string Name { get; init; } = "";
public long DepId { get; init; }
public long WorktypeId { get; init; }
public bool IsZhuanzheng { get; init; }
}
private async Task<Ctx> BuildContextAsync()
{
var depts = await _deptRepo.GetAllAsync();
var wts = await _wtRepo.GetAllAsync();
var emps = await _empRepo.GetAllAsync();
return new Ctx
{
DeptNames = depts.ToDictionary(d => d.Id, d => d.Name),
WorktypeNames = wts.ToDictionary(w => w.Id, w => w.WorkName),
Emps = emps.ToDictionary(e => e.Id, e => new EmpInfo
{
Id = e.Id,
Name = e.Name,
DepId = e.DepId,
WorktypeId = e.WorktypeId,
IsZhuanzheng = e.IsZhuanzheng
})
};
}
private static KtjlctgReadDto_View ToCtgView(KtjlCtg x, Ctx ctx) => new()
{
Id = x.Id,
Date = x.Date,
DepId = x.DeptId,
DepName = ctx.DeptNames.GetValueOrDefault(x.DeptId, ""),
Banci = x.Banci,
BanciName = x.Banci.ToString(),
Jizu = x.Jizu,
Hours = x.Hours,
EmployeeId = x.EmployeeId,
EmployeeName = ctx.Emps.GetValueOrDefault(x.EmployeeId)?.Name ?? "",
EmployeeDepId = x.EmployeeDepId,
EmployeeDepName = ctx.DeptNames.GetValueOrDefault(x.EmployeeDepId, ""),
WorktypeId = x.WorktypeId,
WorktypeName = string.IsNullOrWhiteSpace(x.WorktypeName)
? ctx.WorktypeNames.GetValueOrDefault(x.WorktypeId, "")
: x.WorktypeName,
NoChanliang = x.NoChanliang
};
private static KtjldcgReadDto_View ToDcgView(KtjlDcg x, Ctx ctx) => new()
{
Id = x.Id,
Date = x.Date,
DepId = x.DeptId,
DepName = ctx.DeptNames.GetValueOrDefault(x.DeptId, ""),
Banci = x.Banci,
BanciName = x.Banci.ToString(),
Jizu = x.Jizu,
ChetaiStart = x.ChetaiStart,
ChetaiEnd = x.ChetaiEnd,
DangcheNum = x.DangcheNum,
Hours = x.Hours,
EmployeeId = x.EmployeeId,
EmployeeName = ctx.Emps.GetValueOrDefault(x.EmployeeId)?.Name ?? "",
EmployeeDepId = x.EmployeeDepId,
EmployeeDepName = ctx.DeptNames.GetValueOrDefault(x.EmployeeDepId, ""),
WorktypeId = x.WorktypeId,
WorktypeName = string.IsNullOrWhiteSpace(x.WorktypeName)
? ctx.WorktypeNames.GetValueOrDefault(x.WorktypeId, "")
: x.WorktypeName,
NoChanliang = x.NoChanliang
};
}
@@ -0,0 +1,43 @@
using FreeSql.DataAnnotations;
using F9WebAiTest.Domain.Common;
using F9WebAiTest.Domain.Enums.SYS.Employees;
namespace F9WebAiTest.Domain.Entities.Prod;
// 看台记录(对应 F9 生产-前缫 的 KTJL 两张表:车头工 / 挡车工)
// 记录 = 某日某车间某班次的实际看台情况(由看台排班带出后再调整:车位、时长、代班、不计产量)。
/// <summary>车头工看台记录(biz_prod_qs_ktjl_ctg)</summary>
[Table(Name = "KtjlCtgs")]
public class KtjlCtg : BaseEntity
{
public DateTime Date { get; set; } // 日期
public long DeptId { get; set; } // 车间
public Prod_Banci_Enum Banci { get; set; } // 班次(甲/乙)
public int Jizu { get; set; } // 机组
public decimal Hours { get; set; } // 时长(小时)
public long EmployeeId { get; set; } // 车头工
public long EmployeeDepId { get; set; } // 职工所属部门
public long WorktypeId { get; set; } // 工种
public string WorktypeName { get; set; } = ""; // 工种名称(历史快照)
public bool NoChanliang { get; set; } // 不计产量
}
/// <summary>挡车工看台记录(biz_prod_qs_ktjl_dcg)</summary>
[Table(Name = "KtjlDcgs")]
public class KtjlDcg : BaseEntity
{
public DateTime Date { get; set; } // 日期
public long DeptId { get; set; } // 车间
public Prod_Banci_Enum Banci { get; set; } // 班次
public int Jizu { get; set; } // 机组
public int ChetaiStart { get; set; } // 开始车位
public int ChetaiEnd { get; set; } // 结束车位
public decimal DangcheNum { get; set; } // 挡车数(边界车位与他人共用时各计 0.5)
public decimal Hours { get; set; } // 时长(小时)
public long EmployeeId { get; set; } // 挡车工
public long EmployeeDepId { get; set; } // 职工所属部门
public long WorktypeId { get; set; } // 工种
public string WorktypeName { get; set; } = ""; // 工种名称(历史快照)
public bool NoChanliang { get; set; } // 不计产量
}
@@ -737,14 +737,16 @@ public static class DbSeeder
("机组车台", "/prod/qianxian/jizuchetai", "base/jizuchetai/index", "prod:jizuchetai:list", "SetUp", 1), ("机组车台", "/prod/qianxian/jizuchetai", "base/jizuchetai/index", "prod:jizuchetai:list", "SetUp", 1),
("落丝记录", "/prod/qianxian/lsjl", "prod/lsjl/index", "prod:lsjl:list", "Document", 2), ("落丝记录", "/prod/qianxian/lsjl", "prod/lsjl/index", "prod:lsjl:list", "Document", 2),
("车间班次", "/prod/qianxian/chejianbanci", "base/chejianbanci/index", "prod:chejianbanci:list", "Timer", 3), ("车间班次", "/prod/qianxian/chejianbanci", "base/chejianbanci/index", "prod:chejianbanci:list", "Timer", 3),
("看台排班", "/prod/qianxian/ktpb", "prod/ktpb/index", "prod:ktpb:list", "Grid", 4) ("看台排班", "/prod/qianxian/ktpb", "prod/ktpb/index", "prod:ktpb:list", "Grid", 4),
("看台记录", "/prod/qianxian/ktjl", "prod/ktjl/index", "prod:ktjl:list", "Tickets", 5)
}); });
// 子菜单顺序归一(EnsureTopMenuWithChildren 只补新菜单,不调整已存在菜单的排序) // 子菜单顺序归一(EnsureTopMenuWithChildren 只补新菜单,不调整已存在菜单的排序)
foreach (var (path, sort) in new (string Path, int Sort)[] foreach (var (path, sort) in new (string Path, int Sort)[]
{ {
("/prod/qianxian/jizuchetai", 1), ("/prod/qianxian/lsjl", 2), ("/prod/qianxian/jizuchetai", 1), ("/prod/qianxian/lsjl", 2),
("/prod/qianxian/chejianbanci", 3), ("/prod/qianxian/ktpb", 4) ("/prod/qianxian/chejianbanci", 3), ("/prod/qianxian/ktpb", 4),
("/prod/qianxian/ktjl", 5)
}) })
{ {
fsql.Update<SysMenu>() fsql.Update<SysMenu>()
@@ -8,6 +8,7 @@ using F9WebAiTest.Domain.Entities.Prod;
using F9WebAiTest.Domain.Enums.SYS.Employees; using F9WebAiTest.Domain.Enums.SYS.Employees;
using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments; using SysDept = F9WebAiTest.Domain.Entities.SYS.Departments;
using SysEmp = F9WebAiTest.Domain.Entities.SYS.Employees; using SysEmp = F9WebAiTest.Domain.Entities.SYS.Employees;
using SysWt = F9WebAiTest.Domain.Entities.SYS.WorkTypes;
namespace F9WebAiTest.Infrastructure.Data; namespace F9WebAiTest.Infrastructure.Data;
@@ -24,6 +25,8 @@ public static class ProdSeedData
private const string KtpbCtgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_ctg.json"; private const string KtpbCtgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_ctg.json";
private const string KtpbDcgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_dcg.json"; private const string KtpbDcgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_dcg.json";
private const string KtpbZbzResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_zbz.json"; private const string KtpbZbzResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktpb_zbz.json";
private const string KtjlCtgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktjl_ctg.csv";
private const string KtjlDcgResource = "F9WebAiTest.Infrastructure.Data.SeedData.biz_prod_qs_ktjl_dcg.csv";
private static readonly JsonSerializerOptions JsonOpts = new() private static readonly JsonSerializerOptions JsonOpts = new()
{ {
@@ -39,6 +42,9 @@ public static class ProdSeedData
SeedKtpbCtg(fsql); SeedKtpbCtg(fsql);
SeedKtpbDcg(fsql); SeedKtpbDcg(fsql);
SeedKtpbZbz(fsql); SeedKtpbZbz(fsql);
// 看台记录(历史流水,一次性导入)
SeedKtjlCtg(fsql);
SeedKtjlDcg(fsql);
} }
// ==================== 机组车台 ==================== // ==================== 机组车台 ====================
@@ -310,6 +316,146 @@ public static class ProdSeedData
} }
} }
// ==================== 看台记录 ====================
/// <summary>
/// 车头工看台记录(历史流水):一次性导入,表内已有数据则跳过。
/// 数据格式:date|depId|banci|jizu|hours|employeeId|employeeDepId|worktypeId|noChanliang
/// </summary>
private static void SeedKtjlCtg(IFreeSql fsql)
{
try
{
if (fsql.Select<KtjlCtg>().Any()) { return; }
var lines = LoadCsv(KtjlCtgResource);
if (lines.Count == 0) return;
var deptMap = BuildDeptMap(fsql);
var empIds = BuildEmpIdSet(fsql);
var wtNames = fsql.Select<SysWt.WorkType>().ToList().ToDictionary(w => w.Id, w => w.WorkName);
var batch = new List<KtjlCtg>(1000);
int added = 0, skipped = 0;
foreach (var line in lines)
{
var f = line.Split('|');
if (f.Length < 9) { skipped++; continue; }
if (!DateTime.TryParse(f[0], out var date)) { skipped++; continue; }
if (!deptMap.TryGetValue(long.Parse(f[1]), out var depId)) { skipped++; continue; }
if (!empIds.Contains(long.Parse(f[5]))) { skipped++; continue; }
var wtId = long.Parse(f[7]);
batch.Add(new KtjlCtg
{
Date = date.Date,
DeptId = depId,
Banci = (Prod_Banci_Enum)int.Parse(f[2]),
Jizu = int.Parse(f[3]),
Hours = decimal.Parse(f[4]),
EmployeeId = long.Parse(f[5]),
EmployeeDepId = deptMap.TryGetValue(long.Parse(f[6]), out var ed) ? ed : depId,
WorktypeId = wtId,
WorktypeName = wtNames.GetValueOrDefault(wtId, ""),
NoChanliang = f[8] == "1" || f[8].Equals("true", StringComparison.OrdinalIgnoreCase)
});
if (batch.Count >= 1000)
{
fsql.Insert(batch).ExecuteAffrows();
added += batch.Count;
batch.Clear();
}
}
if (batch.Count > 0)
{
fsql.Insert(batch).ExecuteAffrows();
added += batch.Count;
}
Console.WriteLine($"[ProdSeed] 车头工看台记录:来源 {lines.Count} 行,导入 {added} 行,跳过 {skipped} 行");
}
catch (Exception ex)
{
Console.WriteLine($"[ProdSeed] 车头工看台记录导入跳过:{ex.Message}");
}
}
/// <summary>
/// 挡车工看台记录(历史流水):一次性导入,表内已有数据则跳过。
/// 数据格式:date|depId|banci|jizu|chetaiStart|chetaiEnd|dangcheNum|hours|employeeId|employeeDepId|worktypeId|noChanliang
/// </summary>
private static void SeedKtjlDcg(IFreeSql fsql)
{
try
{
if (fsql.Select<KtjlDcg>().Any()) { return; }
var lines = LoadCsv(KtjlDcgResource);
if (lines.Count == 0) return;
var deptMap = BuildDeptMap(fsql);
var empIds = BuildEmpIdSet(fsql);
var wtNames = fsql.Select<SysWt.WorkType>().ToList().ToDictionary(w => w.Id, w => w.WorkName);
var batch = new List<KtjlDcg>(1000);
int added = 0, skipped = 0;
foreach (var line in lines)
{
var f = line.Split('|');
if (f.Length < 12) { skipped++; continue; }
if (!DateTime.TryParse(f[0], out var date)) { skipped++; continue; }
if (!deptMap.TryGetValue(long.Parse(f[1]), out var depId)) { skipped++; continue; }
if (!empIds.Contains(long.Parse(f[8]))) { skipped++; continue; }
var wtId = long.Parse(f[10]);
batch.Add(new KtjlDcg
{
Date = date.Date,
DeptId = depId,
Banci = (Prod_Banci_Enum)int.Parse(f[2]),
Jizu = int.Parse(f[3]),
ChetaiStart = int.Parse(f[4]),
ChetaiEnd = int.Parse(f[5]),
DangcheNum = decimal.Parse(f[6]),
Hours = decimal.Parse(f[7]),
EmployeeId = long.Parse(f[8]),
EmployeeDepId = deptMap.TryGetValue(long.Parse(f[9]), out var ed) ? ed : depId,
WorktypeId = wtId,
WorktypeName = wtNames.GetValueOrDefault(wtId, ""),
NoChanliang = f[11] == "1" || f[11].Equals("true", StringComparison.OrdinalIgnoreCase)
});
if (batch.Count >= 1000)
{
fsql.Insert(batch).ExecuteAffrows();
added += batch.Count;
batch.Clear();
}
}
if (batch.Count > 0)
{
fsql.Insert(batch).ExecuteAffrows();
added += batch.Count;
}
Console.WriteLine($"[ProdSeed] 挡车工看台记录:来源 {lines.Count} 行,导入 {added} 行,跳过 {skipped} 行");
}
catch (Exception ex)
{
Console.WriteLine($"[ProdSeed] 挡车工看台记录导入跳过:{ex.Message}");
}
}
private static List<string> LoadCsv(string resourceName)
{
var asm = Assembly.GetExecutingAssembly();
using var stream = asm.GetManifestResourceStream(resourceName);
if (stream == null) return new List<string>();
using var reader = new StreamReader(stream);
var list = new List<string>();
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(line)) list.Add(line);
}
return list;
}
// ==================== 公共 ==================== // ==================== 公共 ====================
/// <summary>本系统存在的职工 Id 集合(F9 职工导出时保留了原始雪花 Id,可直接比对)</summary> /// <summary>本系统存在的职工 Id 集合(F9 职工导出时保留了原始雪花 Id,可直接比对)</summary>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,8 @@
<ItemGroup> <ItemGroup>
<!-- 工种基础数据种子(来源于 F9 系统 biz_base_worktype 三张表导出) --> <!-- 工种基础数据种子(来源于 F9 系统 biz_base_worktype 三张表导出) -->
<EmbeddedResource Include="Data\SeedData\*.json" /> <EmbeddedResource Include="Data\SeedData\*.json" />
<!-- 看台记录历史流水(体量较大,采用紧凑 CSV 内嵌) -->
<EmbeddedResource Include="Data\SeedData\*.csv" />
<!-- 报表中心示例 .grf 模板(GR6.8.5.0 格式;GR7 设计器向下兼容,可直接打开编辑) --> <!-- 报表中心示例 .grf 模板(GR6.8.5.0 格式;GR7 设计器向下兼容,可直接打开编辑) -->
<EmbeddedResource Include="Assets\ReportTemplates\*.grf" /> <EmbeddedResource Include="Assets\ReportTemplates\*.grf" />
</ItemGroup> </ItemGroup>
+1
View File
@@ -66,6 +66,7 @@ export const componentMap = {
'prod/xdjy/index': () => import('../views/prod/xdjy/index.vue'), 'prod/xdjy/index': () => import('../views/prod/xdjy/index.vue'),
'prod/hbjy/index': () => import('../views/prod/hbjy/index.vue'), 'prod/hbjy/index': () => import('../views/prod/hbjy/index.vue'),
'prod/ktpb/index': () => import('../views/prod/ktpb/index.vue'), 'prod/ktpb/index': () => import('../views/prod/ktpb/index.vue'),
'prod/ktjl/index': () => import('../views/prod/ktjl/index.vue'),
'notification/messages/index': () => import('../views/notification/messages/index.vue'), 'notification/messages/index': () => import('../views/notification/messages/index.vue'),
'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'), 'notification/subscribe/index': () => import('../views/notification/subscribe/index.vue'),
'notification/type/index': () => import('../views/notification/type/index.vue'), 'notification/type/index': () => import('../views/notification/type/index.vue'),
+347
View File
@@ -0,0 +1,347 @@
<script setup>
/**
* 看台记录录入弹窗(对应 F9 Page_Ktjl_Dcg_Edit / Page_Ktjl_Ctg_Edit)
* - 挡车工:可编辑机组/起止车位/挡车数/时长,挡车数按接壤车位自动计算
* - 车头工:可编辑机组/时长
* - 校验口径与 F9 一致:个人时长不超标准、挡车数/看台时长匹配、车头工每机组≥2 人且时长为标准时长整数倍
*/
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import request from '../../../utils/request';
import EmployeeSelect from '../../../components/select/EmployeeSelect.vue';
const props = defineProps({
modelValue: Boolean,
type: { type: String, default: 'dcg' }, // dcg | ctg
date: { type: String, default: '' },
depId: { type: Number, default: 0 },
depName: { type: String, default: '' },
banci: { type: Number, default: 1 },
banciName: { type: String, default: '甲' },
/** 预置行(录入(新增) 的排班带出 / 指定日期复制),库中无记录时使用 */
presetRows: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue', 'saved']);
const visible = ref(false);
const loading = ref(false);
const saving = ref(false);
const rows = ref([]);
const hoursStandard = ref(9);
const jizuOptions = ref([]);
const empPicker = reactive({ visible: false });
const selected = ref([]);
const title = computed(() => (props.type === 'dcg' ? '挡车工看台记录' : '车头工看台记录'));
watch(() => props.modelValue, async (v) => {
visible.value = v;
if (v) await load();
});
watch(visible, (v) => emit('update:modelValue', v));
async function load() {
loading.value = true;
try {
const res = await request.get(`/prod/ktjl/${props.type}`, {
params: { date: props.date, depId: props.depId, banci: props.banci },
});
const list = (res.data || []).length ? res.data : props.presetRows || [];
rows.value = list.map((x) => ({ ...x, selected: false }));
const jz = await request.get('/prod/ktpb/jizu-options', { params: { depId: props.depId } });
jizuOptions.value = jz.data || [];
hoursStandard.value = rows.value.length ? Math.max(...rows.value.map((x) => Number(x.hours) || 0)) : 9;
if (props.type === 'dcg') recalcDcg();
} finally {
loading.value = false;
}
}
/** 由车位推导机组(与后端 CalJizu 一致) */
function jizuOf(start, end) {
const hit = jizuOptions.value.find((j) => j.chetaiStart <= end && j.chetaiEnd >= start);
return hit ? hit.jizu : 0;
}
/** 挡车数:车位数 - 与他人在边界车位各分 0.5(与后端 CalDangcheNum 一致) */
function recalcDcg() {
const list = rows.value;
list.forEach((it) => {
const s = Number(it.chetaiStart) || 0;
const e = Number(it.chetaiEnd) || 0;
if (!s || e < s) {
it.dangcheNum = 0;
return;
}
const startShared = list.some((x) => x !== it && x.employeeId !== it.employeeId && Number(x.chetaiEnd) === s);
const endShared = list.some((x) => x !== it && x.employeeId !== it.employeeId && Number(x.chetaiStart) === e);
it.dangcheNum = e + 1 - s - (startShared ? 0.5 : 0) - (endShared ? 0.5 : 0);
it.jizu = jizuOf(s, e);
});
}
function onChetaiChange() {
recalcDcg();
}
/** 添加职工(多选) */
async function openEmpPicker() {
empPicker.visible = true;
}
async function onEmployeesPicked(list) {
const exist = new Set(rows.value.map((x) => String(x.employeeId)));
const picked = Array.isArray(list) ? list : [list];
picked.forEach((e) => {
if (exist.has(String(e.id))) return;
rows.value.push({
id: 0,
employeeId: e.id,
employeeName: e.name,
employeeDepId: e.depId,
employeeDepName: e.depName,
worktypeId: e.worktypeId,
worktypeName: e.worktypeName,
jizu: props.type === 'dcg' ? 0 : 1,
chetaiStart: 0,
chetaiEnd: 0,
dangcheNum: 0,
hours: Number(hoursStandard.value) || 9,
noChanliang: false,
selected: false,
});
});
if (props.type === 'dcg') recalcDcg();
}
function removeSelected() {
const picked = rows.value.filter((x) => x.selected);
if (!picked.length) return ElMessage.warning('请选择要删除的记录');
rows.value = rows.value.filter((x) => !x.selected);
if (props.type === 'dcg') recalcDcg();
}
async function batchHours() {
const picked = rows.value.filter((x) => x.selected);
if (!picked.length) return ElMessage.warning('请选择要修改的记录');
try {
const { value } = await ElMessageBox.prompt('批量设置时长(小时)', '设置时长', {
inputValue: String(hoursStandard.value),
inputPattern: /^\d+(\.\d+)?$/,
inputErrorMessage: '请输入数字',
});
picked.forEach((x) => {
x.hours = Number(value);
});
} catch {
/* 取消 */
}
}
/** 校验(对齐 F9 ValidCustom),返回提示列表 */
function validate() {
const warns = [];
const std = Number(hoursStandard.value) || 0;
if (rows.value.some((x) => !x.employeeId)) {
return { fatal: '不能有空数据(存在未选择职工的记录)', warns: [] };
}
if (props.type === 'dcg') {
const over = [];
const byEmp = {};
rows.value.forEach((x) => {
const key = String(x.employeeId);
byEmp[key] = byEmp[key] || { name: x.employeeName, hours: 0 };
byEmp[key].hours += Number(x.hours) || 0;
});
Object.values(byEmp).forEach((v) => {
if (std > 0 && v.hours > std) over.push(v.name);
});
if (over.length) return { fatal: `${over.join('、')} 超过标准时长(${std} 小时)`, warns: [] };
rows.value.forEach((x) => {
const s = Number(x.chetaiStart);
const e = Number(x.chetaiEnd);
const shared = rows.value.some((y) => y.employeeId !== x.employeeId && (Number(y.chetaiEnd) === s || Number(y.chetaiStart) === e));
if (shared && Number(x.dangcheNum) * 10 % 10 === 0) {
warns.push(`${x.jizu}组${e}台,多人看台挡车数不匹配`);
}
if (Number(x.dangcheNum) === 0) warns.push(`${x.jizu}组${e}台,挡车数不能为 0`);
});
// 看台时长校验
const jizus = [...new Set(rows.value.map((x) => x.jizu))];
jizus.forEach((jz) => {
const items = rows.value.filter((x) => x.jizu === jz);
if (!items.length) return;
const min = Math.min(...items.map((x) => Number(x.chetaiStart)));
const max = Math.max(...items.map((x) => Number(x.chetaiEnd)));
for (let i = min; i <= max; i++) {
const cover = items.filter((x) => Number(x.chetaiStart) <= i && Number(x.chetaiEnd) >= i);
if (!cover.length) continue;
const hasStart = cover.some((x) => Number(x.chetaiStart) === i);
const hasEnd = cover.some((x) => Number(x.chetaiEnd) === i);
if (hasStart && hasEnd) {
const up = cover.filter((x) => Number(x.chetaiStart) === i).reduce((a, b) => a + Number(b.hours), 0);
const down = cover.filter((x) => Number(x.chetaiEnd) === i).reduce((a, b) => a + Number(b.hours), 0);
if (up === down) {
if (std > 0 && up !== std) warns.push(`${jz}组${i}台,看台时长有误`);
} else {
warns.push(`${jz}组${i}台,看台时长不一致`);
}
} else {
const sum = cover.reduce((a, b) => a + Number(b.hours), 0);
if (std > 0 && sum !== std) warns.push(`${jz}组${i}台,看台时长有误`);
}
}
});
} else {
const jizus = [...new Set(rows.value.map((x) => x.jizu))];
jizus.forEach((jz) => {
const items = rows.value.filter((x) => x.jizu === jz);
if (items.length < 2) warns.push(`${jz}组不足两人`);
if (items.some((x) => Number(x.hours) === 0)) warns.push(`${jz}组时长有误`);
const sum = items.reduce((a, b) => a + Number(b.hours), 0);
if (std > 0 && (sum < std * 2 || sum % std !== 0)) warns.push(`${jz}组时长有误`);
});
}
return { fatal: '', warns };
}
async function save() {
const { fatal, warns } = validate();
if (fatal) return ElMessage.warning(fatal);
if (warns.length) {
try {
await ElMessageBox.confirm(`${[...new Set(warns)].join('\n')}\n是否继续保存?`, '验证提示', {
type: 'warning',
confirmButtonText: '继续保存',
cancelButtonText: '返回修改',
});
} catch {
return;
}
}
saving.value = true;
try {
const res = await request.post(`/prod/ktjl/${props.type}/batch`, {
date: props.date,
depId: props.depId,
banci: props.banci,
items: rows.value.map((x) => ({
employeeId: x.employeeId,
employeeDepId: x.employeeDepId,
worktypeId: x.worktypeId,
worktypeName: x.worktypeName,
jizu: Number(x.jizu) || 0,
chetaiStart: Number(x.chetaiStart) || 0,
chetaiEnd: Number(x.chetaiEnd) || 0,
dangcheNum: Number(x.dangcheNum) || 0,
hours: Number(x.hours) || 0,
noChanliang: !!x.noChanliang,
})),
});
ElMessage.success(res.message || '保存成功');
visible.value = false;
emit('saved');
} finally {
saving.value = false;
}
}
</script>
<template>
<el-dialog v-model="visible" :title="title" width="1080px" destroy-on-close append-to-body>
<div class="dlg-head">
<el-tag type="info">{{ date }}</el-tag>
<el-tag type="info">{{ depName }}</el-tag>
<el-tag type="info">{{ banciName }}班</el-tag>
<span class="lbl">标准时长</span>
<el-input-number v-model="hoursStandard" :min="1" :max="24" :step="0.5" :controls="false" style="width: 80px" />
<span class="tip">共 {{ rows.length }} 条</span>
</div>
<div class="dlg-toolbar">
<el-button type="primary" size="small" @click="openEmpPicker">添加职工</el-button>
<el-button size="small" @click="removeSelected">删除选中</el-button>
<el-button size="small" @click="batchHours">批量修改时长</el-button>
<span class="hint">
{{ type === 'dcg'
? '挡车数 = 车位数 −(与他人接壤的边界车位各 0.5);机组按车位自动归属'
: '每个机组至少 2 人,机组时长合计需为标准时长的整数倍' }}
</span>
</div>
<el-table v-loading="loading" :data="rows" border size="small" height="420" @selection-change="selected = $event">
<el-table-column type="selection" width="42" />
<el-table-column type="index" label="序号" width="52" />
<el-table-column prop="employeeDepName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default>{{ banciName }}</template>
</el-table-column>
<el-table-column label="机组" width="80" align="center">
<template #default="{ row }">
<el-input-number v-model="row.jizu" :min="0" :max="99" :controls="false" size="small" style="width: 60px" />
</template>
</el-table-column>
<template v-if="type === 'dcg'">
<el-table-column label="开始车位" width="96" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.chetaiStart"
:min="1"
:max="999"
:controls="false"
size="small"
style="width: 76px"
@change="onChetaiChange"
/>
</template>
</el-table-column>
<el-table-column label="结束车位" width="96" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.chetaiEnd"
:min="1"
:max="999"
:controls="false"
size="small"
style="width: 76px"
@change="onChetaiChange"
/>
</template>
</el-table-column>
<el-table-column prop="dangcheNum" label="挡车数" width="80" align="right" />
</template>
<el-table-column label="时长" width="90" align="center">
<template #default="{ row }">
<el-input-number v-model="row.hours" :min="0" :max="24" :step="0.5" :controls="false" size="small" style="width: 70px" />
</template>
</el-table-column>
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">
<el-switch v-model="row.noChanliang" size="small" />
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
<EmployeeSelect v-model="empPicker.visible" multiple :dep-id="depId" @confirm="onEmployeesPicked" />
</el-dialog>
</template>
<style scoped>
.dlg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.dlg-head .lbl { color: var(--el-text-color-regular); font-size: 13px; margin-left: 6px; }
.dlg-head .tip { margin-left: auto; color: var(--el-text-color-secondary); font-size: 13px; }
.dlg-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.dlg-toolbar .hint { color: var(--el-text-color-secondary); font-size: 12px; }
</style>
+260
View File
@@ -0,0 +1,260 @@
<script setup>
/**
* 看台记录(生产-前缫)
* 对应 WinForms 端 WinClient\F9.Business.Prod\QS\KTJL\Page_Ktjl
* - 两个页签:挡车工 / 车头工
* - 过滤:日期 + 车间 + 班次(关键字)
* - 按钮:删除(按日期+车间+班次整批)、录入(新增)(按看台排班带出)、录入(指定日期)(复制某日记录)
*/
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import request from '../../../utils/request';
import KtjlEditDialog from './KtjlEditDialog.vue';
const TABS = [
{ name: 'dcg', label: '挡车工' },
{ name: 'ctg', label: '车头工' },
];
const BANCI = [
{ value: 1, label: '甲班' },
{ value: 2, label: '乙班' },
];
function today() {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${dd}`;
}
const tab = ref('dcg');
const deps = ref([]);
const filter = reactive({ date: today(), depId: 0, banci: 1, keyword: '' });
const state = reactive({
dcg: { rows: [], loading: false },
ctg: { rows: [], loading: false },
});
const cur = computed(() => state[tab.value]);
const banciName = computed(() => (filter.banci === 2 ? '乙' : '甲'));
const depName = computed(() => deps.value.find((d) => d.id === filter.depId)?.name || '');
const dlg = reactive({ visible: false, type: 'dcg', presetRows: [] });
const copyDlg = reactive({ visible: false, date: '' });
async function loadDeps() {
const res = await request.get('/prod/ktpb/qianxian-deps');
deps.value = res.data || [];
if (deps.value.length && !filter.depId) filter.depId = deps.value[0].id;
}
async function load() {
if (!filter.depId) return;
const s = cur.value;
s.loading = true;
try {
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci, like: filter.keyword },
});
s.rows = res.data || [];
} finally {
s.loading = false;
}
}
onMounted(async () => {
await loadDeps();
await load();
});
async function onTabChange() {
await load();
}
/** 录入(新增):已有记录则继续编辑,否则按看台排班带出默认记录 */
async function openNew() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
const exist = res.data || [];
if (exist.length) {
dlg.presetRows = [];
} else {
const def = await request.get(`/prod/ktjl/${tab.value}/defaults`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
dlg.presetRows = def.data || [];
if (!dlg.presetRows.length) {
ElMessage.warning('该车间该班次暂无看台排班,请先维护「看台排班」或手工添加职工');
}
}
dlg.type = tab.value;
dlg.visible = true;
}
/** 录入(指定日期):把某一天的记录复制到当前日期再编辑 */
async function openCopy() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
copyDlg.date = '';
copyDlg.visible = true;
}
async function doCopy() {
if (!copyDlg.date) return ElMessage.warning('请选择来源日期');
const res = await request.get(`/prod/ktjl/${tab.value}`, {
params: { date: copyDlg.date, depId: filter.depId, banci: filter.banci },
});
const list = (res.data || []).map((x) => ({ ...x, id: 0 }));
if (!list.length) return ElMessage.warning('来源日期没有看台记录');
copyDlg.visible = false;
dlg.presetRows = list;
dlg.type = tab.value;
dlg.visible = true;
}
async function removeGroup() {
if (!filter.depId) return ElMessage.warning('请先选择车间');
if (!cur.value.rows.length) return ElMessage.warning('当前没有可删除的记录');
try {
await ElMessageBox.confirm(
`确认删除 ${filter.date},${depName.value},${banciName.value}班 的全部${tab.value === 'dcg' ? '挡车工' : '车头工'}看台记录?`,
'提示',
{ type: 'warning' }
);
} catch {
return;
}
const res = await request.delete(`/prod/ktjl/${tab.value}`, {
params: { date: filter.date, depId: filter.depId, banci: filter.banci },
});
ElMessage.success(res.message || '删除成功');
await load();
}
async function onSaved() {
await load();
}
</script>
<template>
<div class="ktjl-page">
<el-card shadow="never">
<el-tabs v-model="tab" @tab-change="onTabChange">
<el-tab-pane v-for="t in TABS" :key="t.name" :label="t.label" :name="t.name" />
</el-tabs>
<div class="toolbar">
<span class="lbl">日期</span>
<el-date-picker
v-model="filter.date"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
style="width: 150px"
@change="load"
/>
<span class="lbl">车间</span>
<el-select v-model="filter.depId" placeholder="请选择车间" style="width: 160px" @change="load">
<el-option v-for="d in deps" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<span class="lbl">班次</span>
<el-select v-model="filter.banci" style="width: 110px" @change="load">
<el-option v-for="b in BANCI" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<el-input
v-model="filter.keyword"
placeholder="姓名 / 工种 / 机组"
clearable
style="width: 180px"
@keyup.enter="load"
@clear="load"
/>
<el-button type="primary" @click="load">查询</el-button>
<div class="right">
<el-button type="danger" plain @click="removeGroup">删除</el-button>
<el-button type="primary" @click="openNew">录入(新增)</el-button>
<el-button @click="openCopy">录入(指定日期)</el-button>
</div>
</div>
<!-- 挡车工 -->
<el-table v-if="tab === 'dcg'" v-loading="state.dcg.loading" :data="state.dcg.rows" border size="small">
<el-table-column type="index" label="序号" width="55" />
<el-table-column prop="depName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default="{ row }">{{ row.banciName }}班</template>
</el-table-column>
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeDepName" label="职工部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="jizu" label="机组" width="70" align="center" />
<el-table-column prop="chetaiStart" label="开始车位" width="90" align="center" />
<el-table-column prop="chetaiEnd" label="结束车位" width="90" align="center" />
<el-table-column prop="dangcheNum" label="挡车数" width="80" align="right" />
<el-table-column prop="hours" label="时长" width="70" align="right" />
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">{{ row.noChanliang ? '√' : '' }}</template>
</el-table-column>
<template #empty>
<span class="empty">当日无记录,可点击「录入(新增)」按看台排班带出</span>
</template>
</el-table>
<!-- 车头工 -->
<el-table v-else v-loading="state.ctg.loading" :data="state.ctg.rows" border size="small">
<el-table-column type="index" label="序号" width="55" />
<el-table-column prop="depName" label="部门" min-width="110" show-overflow-tooltip />
<el-table-column label="班次" width="70" align="center">
<template #default="{ row }">{{ row.banciName }}班</template>
</el-table-column>
<el-table-column prop="employeeName" label="姓名" width="120" show-overflow-tooltip />
<el-table-column prop="worktypeName" label="工种" width="90" />
<el-table-column prop="employeeDepName" label="职工部门" min-width="110" show-overflow-tooltip />
<el-table-column prop="jizu" label="机组" width="70" align="center" />
<el-table-column prop="hours" label="时长" width="70" align="right" />
<el-table-column label="不计产量" width="90" align="center">
<template #default="{ row }">{{ row.noChanliang ? '√' : '' }}</template>
</el-table-column>
<template #empty>
<span class="empty">当日无记录,可点击「录入(新增)」按看台排班带出</span>
</template>
</el-table>
</el-card>
<KtjlEditDialog
v-model="dlg.visible"
:type="dlg.type"
:date="filter.date"
:dep-id="filter.depId"
:dep-name="depName"
:banci="filter.banci"
:banci-name="banciName"
:preset-rows="dlg.presetRows"
@saved="onSaved"
/>
<el-dialog v-model="copyDlg.visible" title="选择来源日期" width="360px" append-to-body>
<el-date-picker
v-model="copyDlg.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择要复制的日期"
style="width: 100%"
/>
<div class="copy-tip">将把该日期「{{ depName }} · {{ banciName }}班」的{{ tab === 'dcg' ? '挡车工' : '车头工' }}看台记录复制到 {{ filter.date }} 再编辑保存。</div>
<template #footer>
<el-button @click="copyDlg.visible = false">取消</el-button>
<el-button type="primary" @click="doCopy">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.ktjl-page :deep(.el-card__body) { padding-top: 6px; }
.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.toolbar .lbl { color: var(--el-text-color-regular); font-size: 13px; }
.toolbar .right { margin-left: auto; }
.empty { color: var(--el-text-color-secondary); font-size: 13px; }
.copy-tip { margin-top: 10px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
</style>
+130
View File
@@ -0,0 +1,130 @@
# 工作进度-13:生产-前缫「看台记录」+ 历史数据导入
> 时间:2026-10-22(承接工作进度-12)
> 需求:仿照 `D:\AntaiProject - 副本` 生产-前缫的「看台记录」,在本系统落地并与「看台排班」联动,同时导入两张表数据:
> `biz_prod_qs_ktjl_ctg`(车头工看台记录)、`biz_prod_qs_ktjl_dcg`(挡车工看台记录)。
> 参考实现:`WinClient\F9.Business.Prod\QS\KTJL\`(Page_Ktjl、Page_Ktjl_Dcg_Edit、Page_Ktjl_Ctg_Edit)、
> `Application\CQRS\Business\PROD\QS\KTJL\`(Save/Del/Query)、`Domain\Entities\Business\PROD\QS\KTJL\`。
---
## 一、功能设计
```
生产-前缫
├── 机组车台
├── 落丝记录
├── 车间班次
├── 看台排班
└── 看台记录(本次新增) /prod/qianxian/ktjl
过滤:日期 + 车间(职责类型=前缫)+ 班次(甲/乙)+ 关键字
页签:挡车工 / 车头工
按钮:删除(按 日期+车间+班次 整批)、录入(新增)、录入(指定日期)
```
| F9 行为 | 本系统实现 |
|---|---|
| 列表按 日期+车间+班次 查询,无分页 | 同左(单日数据量小,一次展示) |
| `删除`:按 日期+车间+班次 整批软删 | `DELETE /api/prod/ktjl/{type}?date=&depId=&banci=` |
| `录入(新增)`:库中无记录时,按**看台排班**带出该车间该班次的车头工/挡车工 | `GET .../{type}/defaults`,行默认时长 9 小时、未转正职工默认「不计产量」 |
| `录入(指定日期)`:把某日记录复制到当前日期再编辑 | 前端选来源日期 → 读取 → 重映射日期 → 弹窗编辑 |
| `保存`:GroupBy(date, dep, banci) 先删后建 | 同左(服务端事务内先删后建) |
| 挡车数:`结束-开始+1`,与他人接壤的边界车位各扣 0.5 | 前后端同一算法(服务端保存时重算) |
| 机组:由车位范围反查机组车台 | 同左(服务端保存时校正) |
录入弹窗校验(对齐 F9 ValidCustom):
- 车头工:不能有空职工;每机组人数 ≥ 2;每机组时长合计为「标准时长」的整数倍且 ≥ 2 倍标准。
- 挡车工:不能有空职工;个人时长合计不超过标准时长;挡车数与多人看台接壤关系需匹配(挡车数不能为 0);每台车位的看台时长合计需等于标准时长(多人看台按上下游分别校验)。
- 校验不通过分「拦截」和「提示后询问是否继续保存」两级,与 F9 一致。
---
## 二、后端
### 2.1 实体(`Domain/Entities/Prod/Ktjl.cs`)
| 类 | 表 | 字段 |
|---|---|---|
| `KtjlCtg` | `KtjlCtgs` | Date、DeptId、Banci、Jizu、Hours、EmployeeId、EmployeeDepId、WorktypeId、WorktypeName、NoChanliang |
| `KtjlDcg` | `KtjlDcgs` | 同上 + ChetaiStart、ChetaiEnd、DangcheNum |
> `WorktypeName` 为历史快照(F9 是查询时联表),避免工种改名后历史记录显示漂移。
### 2.2 服务与接口(`KtjlService` / `KtjlController`,路由 `api/prod/ktjl`)
```
GET /api/prod/ktjl/dcg?date=&depId=&banci=&like=
GET /api/prod/ktjl/ctg?date=&depId=&banci=&like=
GET /api/prod/ktjl/dcg/defaults?date=&depId=&banci= 按看台排班带出默认记录
GET /api/prod/ktjl/ctg/defaults?date=&depId=&banci=
POST /api/prod/ktjl/dcg/batch 整组保存(先删后建)
POST /api/prod/ktjl/ctg/batch
DELETE /api/prod/ktjl/dcg?date=&depId=&banci=
DELETE /api/prod/ktjl/ctg?date=&depId=&banci=
GET /api/prod/ktjl/worktypes 工种下拉
```
### 2.3 数据导入(`ProdSeedData` 扩展)
- 新增两个内嵌资源:`Data/SeedData/biz_prod_qs_ktjl_ctg.csv`、`biz_prod_qs_ktjl_dcg.csv`
(共 5 万行,JSON 体积过大,改用紧凑的 `|` 分隔 CSV;csproj 增加 `*.csv` 内嵌规则);
- 清洗规则:剔除 `deleted_at` 非空的软删除行、日期序列号异常行(如 25569=1970-01-01)、部门无法映射行、职工不存在的行;
- 部门:xlsx 的 `dep_id` 是 F9 部门 Id,经 `BusinessSeedData.DeptData`(F9 Id → 部门名)映射;
职工:F9 职工导出保留了原始雪花 Id,与本系统 `Employees.Id` 一致,直接关联;
工种:本系统工种保留 F9 雪花 Id,直接对应,并冗余 `WorktypeName`;
- 日期:Excel 序列号(如 46007)转 `yyyy-MM-dd`;
- 幂等:表内已有数据则整体跳过(历史流水快照,不重复导入),分批 1000 行插入。
导入结果(本次启动日志):
```
[ProdSeed] 车头工看台记录:来源 12693 行,导入 12693 行,跳过 0 行
[ProdSeed] 挡车工看台记录:来源 38048 行,导入 38048 行,跳过 0 行
```
原始数据:车头工 16727 行(已删 4034)、挡车工 59744 行(已删 21692、日期异常 1、职工缺失 3);日期范围 **2025-12-16 ~ 2026-09-11**,覆盖前缫一~四车间、甲/乙班、机组 1~6。
### 2.4 菜单
`DbSeeder.SeedProdMenus` 在生产-前缫下新增第 5 个子菜单「看台记录」(`/prod/qianxian/ktjl`,权限 `prod:ktjl:list`),并做排序归一。
---
## 三、前端
| 文件 | 说明 |
|---|---|
| `views/prod/ktjl/index.vue` | 看台记录列表(双页签 + 日期/车间/班次过滤 + 删除/录入/复制日期) |
| `views/prod/ktjl/KtjlEditDialog.vue` | 录入弹窗(可编辑机组/车位/挡车数/时长/不计产量,含 F9 同口径校验) |
| `router/index.js` | 注册 `prod/ktjl/index` 路由组件 |
交互要点:
- 车位改动即时重算挡车数与机组;
- 支持勾选行「批量修改时长」、「删除选中」,可追加职工(多选职工弹窗,按车间过滤);
- 校验提示以对话框列出(如「1组5台,看台时长有误」),可「继续保存」或「返回修改」;
- 列表为空时提示可点「录入(新增)」按排班带出。
---
## 四、验证记录
| 项 | 结果 |
|---|---|
| `dotnet build` | ✅ 0 错误 |
| `vite build` | ✅ 通过 |
| 菜单 | ✅ 生产-前缫 5 个子菜单,看台记录排第 5 |
| 历史数据 | ✅ 车头工 12693 行、挡车工 38048 行入库(日期 2025-12-16 ~ 2026-09-11) |
| 查询 | ✅ 任意日期+车间+班次 返回正确行数与姓名/工种/机组/车位/挡车数/时长 |
| 录入(新增) 默认带出 | ✅ 前缫一车间甲班:挡车工 42 行、车头工 12 行(来自看台排班,时长默认 9) |
| 整组保存 | ✅ 保存 6 条后回读,挡车数 3.5/3.5/3/3.5… 与排班一致 |
| 整组删除 | ✅ 删除后该日该班次 0 行 |
| 车头工保存/回读 | ✅ 4 条,时长 4.5 正常写入 |
---
## 五、后续可做
1. **按职工汇总**:F9 另有 `Ktjl_*_GetAllGroupbyEmployee`(按职工汇总挡车数/时长),可用于工资计件;如需要可加 `GET /api/prod/ktjl/summary?dateFrom=&dateTo=&depId=`。
2. **代班**:F9 录入页支持「他人代班 / 车间内代班」(拆分时长),本版通过「添加职工 + 修改时长 + 调整车位」可等价实现,未做专门按钮。
3. **与落丝记录/工资联动**:看台记录是计件工资的输入之一,后续接入薪酬模块时可直接复用 `KtjlDcgs` 的 `DangcheNum × Hours`。
4. **批量维护**:目前单日单班次维护,若需要跨日期批量复制(如整月排班),可在 `录入(指定日期)` 基础上扩展多选日期。