Compare commits

..

21 Commits

Author SHA1 Message Date
caoqianming e2b1f266aa fix: mlogdefect 空列表时仍同步继承缺陷
前端传 mlogdefect: [] 时,原 is not None 判断会走进 need_mdfect 分支,
既不创建真实缺陷也跳过 sync_inherited_defect,导致 mlogb 无任何缺陷标识。
改为无论哪个分支都兜底调用 sync_inherited_defect,由其内部判断互斥。
2026-04-24 15:45:58 +08:00
TianyangZhang c2ee88d2bf Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-24 15:00:48 +08:00
TianyangZhang 7577a46900 feat: enm 修改重跑能源计算 不用从mplogx 开始计算 2026-04-24 15:00:47 +08:00
caoqianming f6d934bbb1 fix: 重建wpr 2026-04-24 14:52:01 +08:00
caoqianming b6b79da3b1 Inherit batch output defect markers 2026-04-23 16:32:40 +08:00
TianyangZhang 48305ed6fb Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-21 16:05:08 +08:00
TianyangZhang 949620809a feat:光芯科技 主要修改采购功能 2026-04-21 16:05:07 +08:00
caoqianming cafecd4d4a feat: add contract settlement workflows 2026-04-20 16:56:11 +08:00
caoqianming a111f493e1 Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-14 14:16:18 +08:00
caoqianming 3c931040cf feat: batch_bxerp添加子工序操作人 2026-04-14 14:16:17 +08:00
TianyangZhang 159b644126 feat: 修改 hrm & rpm 代码 2026-04-07 13:42:30 +08:00
TianyangZhang a82405e451 feat:修改光芯人员导入功能 2026-03-31 11:08:12 +08:00
caoqianming 44c0787d12 release: 3.1.2026033008 2026-03-30 08:38:38 +08:00
caoqianming b3d0b34719 perf: 优化 Material 可用物料过滤,改为 DB 子查询
- 新增 filter_process_todo 过滤器替代原 todo tag 逻辑
- 避免将 ID 列表加载到 Python 内存,改用两个子查询 OR 合并

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:40:07 +08:00
caoqianming 68191dc305 fix: WMaterialCreateSerializer 所有字段设为必填
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:10:18 +08:00
caoqianming 320822019a feat: WMaterialViewSet 添加手动来料创建和删除接口
- 新增 WMaterialCreateSerializer,validate 中自动推导 belong_dept
- ViewSet 使用 create_serializer_class,perform_create 设置 is_manual=True
- 删除校验逻辑移至模型 delete 方法,校验 is_manual 及关联记录(mlogb/handoverb/ftestwork/mioitem)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 10:08:42 +08:00
caoqianming 61f70d4907 feat: Ptest添加中温粘度规格和结论字段
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 08:58:33 +08:00
caoqianming 3e1a087258 feat: WMaterialViewSet 添加手动创建和删除接口
- WMaterial 新增 is_manual 字段标记手动创建的库存
- WMaterialViewSet 添加 create 接口,创建时自动设置 is_manual=True
- WMaterialViewSet 添加 destroy 接口,仅允许删除手动创建的记录

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:07:42 +08:00
TianyangZhang 6d43b412b7 feat: 修改光芯OA审批的BUG与新增导出功能 2026-03-23 09:50:20 +08:00
TianyangZhang c5e545f5e5 Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-03-13 16:59:13 +08:00
TianyangZhang e3dcb492d7 feat:恢复ichat 功能和 defaut 下的文件 2026-03-13 16:59:12 +08:00
70 changed files with 3001 additions and 242 deletions

View File

@ -13,6 +13,7 @@ class Migration(migrations.Migration):
sql=[ sql=[
( (
""" """
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE public.enm_mplogx ( CREATE TABLE public.enm_mplogx (
"timex" timestamptz NOT NULL, "timex" timestamptz NOT NULL,
"mpoint_id" text NOT NULL, "mpoint_id" text NOT NULL,

View File

@ -206,6 +206,7 @@ class EnStat2Serializer(CustomModelSerializer):
class ReCalSerializer(serializers.Serializer): class ReCalSerializer(serializers.Serializer):
start_time = serializers.DateTimeField(label="开始时间") start_time = serializers.DateTimeField(label="开始时间")
end_time = serializers.DateTimeField(label="结束时间") end_time = serializers.DateTimeField(label="结束时间")
mpoint_stat = serializers.BooleanField(label="从MpointStat开始计算", required=False, default=False)
class MpointStatCorrectSerializer(CustomModelSerializer): class MpointStatCorrectSerializer(CustomModelSerializer):

View File

@ -114,15 +114,20 @@ def db_ins_mplogx():
@shared_task(base=CustomTask) @shared_task(base=CustomTask)
def cal_mpointstats_duration(start_time: str, end_time: str, m_code_list=[], cal_attrs=[]): def cal_mpointstats_duration(start_time: str, end_time: str, m_code_list=[], cal_attrs=[], mpoint_stat=False):
""" """
重跑某一段时间的任务 重跑某一段时间的任务
mpoint_stat: True时从已有的MpointStat hour记录开始重算(跳过MpLogx)只重算day/month/year/sflog聚合速度更快
""" """
mytz = tz.gettz(settings.TIME_ZONE) mytz = tz.gettz(settings.TIME_ZONE)
start_time = datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") start_time = datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
start_time.replace(tzinfo=mytz) start_time = start_time.replace(tzinfo=mytz)
end_time = datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") end_time = datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
start_time.replace(tzinfo=mytz) end_time = end_time.replace(tzinfo=mytz)
if mpoint_stat:
_recal_from_mpointstat(start_time, end_time, m_code_list, cal_attrs)
else:
current_time = start_time current_time = start_time
while current_time <= end_time: while current_time <= end_time:
year, month, day, hour = current_time.year, current_time.month, current_time.day, current_time.hour year, month, day, hour = current_time.year, current_time.month, current_time.day, current_time.hour
@ -131,6 +136,164 @@ def cal_mpointstats_duration(start_time: str, end_time: str, m_code_list=[], cal
current_time += datetime.timedelta(hours=1) current_time += datetime.timedelta(hours=1)
def _recal_from_mpointstat(start_time, end_time, m_code_list=[], cal_attrs=[]):
"""
从已有的MpointStat hour记录开始批量重算day/month/year/sflog聚合
不读取MpLogx速度远快于完整重算
"""
# 确定需要重算的测点
if m_code_list:
mpoints = list(Mpoint.objects.filter(code__in=m_code_list, enabled=True))
# 也要包含依赖这些测点的计算测点
related = Mpoint.objects.none()
for code in m_code_list:
related = related | Mpoint.objects.filter(
type=Mpoint.MT_COMPUTE, enabled=True, material__isnull=False,
formula__contains='{' + code + '}'
)
mpoints.extend(list(related.distinct()))
mpoints = list({mp.id: mp for mp in mpoints}.values()) # 去重
else:
mpoints = list(Mpoint.objects.filter(enabled=True, material__isnull=False))
mpoint_ids = [mp.id for mp in mpoints]
# 收集需要重算的所有 (year, month, day) 和 (year, month)
days_set = set()
months_set = set()
years_set = set()
current_time = start_time
while current_time <= end_time:
days_set.add((current_time.year, current_time.month, current_time.day))
months_set.add((current_time.year, current_time.month))
years_set.add(current_time.year)
current_time += datetime.timedelta(hours=1)
myLogger.info(f"_recal_from_mpointstat: {len(mpoints)} mpoints, {len(days_set)} days")
# 批量重算 day = sum(hour)
for year, month, day in days_set:
# 一次查询拿到所有测点该天的hour汇总
hour_sums = dict(
MpointStat.objects.filter(
type="hour", mpoint_id__in=mpoint_ids, year=year, month=month, day=day
).values('mpoint_id').annotate(total=Sum('val')).values_list('mpoint_id', 'total')
)
for mp in mpoints:
val = hour_sums.get(mp.id)
if val is None:
continue
params_day = {"type": "day", "mpoint": mp, "year": year, "month": month, "day": day}
ms_day, _ = MpointStat.safe_get_or_create(**params_day, defaults=params_day)
if ms_day.val_correct is None:
ms_day.val = val
ms_day.val_origin = val
ms_day.save()
# 批量重算 month = sum(day)
for year, month in months_set:
day_sums = dict(
MpointStat.objects.filter(
type="day", mpoint_id__in=mpoint_ids, year=year, month=month
).values('mpoint_id').annotate(total=Sum('val')).values_list('mpoint_id', 'total')
)
for mp in mpoints:
val = day_sums.get(mp.id)
if val is None:
continue
params_month = {"type": "month", "mpoint": mp, "year": year, "month": month}
ms_month, _ = MpointStat.safe_get_or_create(**params_month, defaults=params_month)
if ms_month.val_correct is None:
ms_month.val = val
ms_month.val_origin = val
ms_month.save()
# 批量重算 year = sum(month)
for year in years_set:
month_sums = dict(
MpointStat.objects.filter(
type="month", mpoint_id__in=mpoint_ids, year=year
).values('mpoint_id').annotate(total=Sum('val')).values_list('mpoint_id', 'total')
)
for mp in mpoints:
val = month_sums.get(mp.id)
if val is None:
continue
params_year = {"type": "year", "mpoint": mp, "year": year}
ms_year, _ = MpointStat.safe_get_or_create(**params_year, defaults=params_year)
if ms_year.val_correct is None:
ms_year.val = val
ms_year.val_origin = val
ms_year.save()
# 重算 sflog 相关统计 (hour_s -> sflog)
mytz = tz.gettz(settings.TIME_ZONE)
mgroups = Mgroup.objects.filter(need_enm=True).order_by("sort")
current_time = start_time
sflog_cache = {}
while current_time <= end_time:
year, month, day, hour = current_time.year, current_time.month, current_time.day, current_time.hour
dt = datetime.datetime(year=year, month=month, day=day, hour=hour, minute=0, second=0, tzinfo=mytz)
for mgroup in mgroups:
cache_key = (mgroup.id, year, month, day, hour)
if cache_key not in sflog_cache:
sflog = get_sflog(mgroup, dt)
sflog_cache[cache_key] = sflog
sflog = sflog_cache[cache_key]
if sflog is None:
continue
year_s, month_s, day_s = sflog.get_ymd
# 获取该mgroup下的测点
group_mpoints = [mp for mp in mpoints if mp.mgroup_id == mgroup.id]
for mp in group_mpoints:
# 找到对应的hour stat
ms_hour = MpointStat.objects.filter(
type="hour", mpoint=mp, year=year, month=month, day=day, hour=hour
).first()
if ms_hour is None:
continue
params_hour_s = {
"type": "hour_s", "mpoint": mp, "sflog": sflog, "mgroup": mgroup,
"year": year, "month": month, "day": day,
"year_s": year_s, "month_s": month_s, "day_s": day_s, "hour": hour,
}
ms_hour_s, _ = MpointStat.safe_get_or_create(**params_hour_s, defaults=params_hour_s)
ms_hour_s.val = ms_hour_s.val_correct if ms_hour_s.val_correct is not None else ms_hour.val
ms_hour_s.save()
# 重算 sflog 聚合
for mp in group_mpoints:
sflog_key = (mp.id, sflog.id, year_s, month_s, day_s)
if sflog_key in sflog_cache:
continue # 同一sflog只算一次
sflog_cache[sflog_key] = True
params_sflog_s = {
"type": "sflog", "mpoint": mp, "sflog": sflog,
"year_s": year_s, "month_s": month_s, "day_s": day_s, "mgroup": mgroup,
}
ms_sflog_s, _ = MpointStat.safe_get_or_create(**params_sflog_s, defaults=params_sflog_s)
if ms_sflog_s.val_correct is None:
sum_val = MpointStat.objects.filter(
type="hour_s", mpoint=mp, year_s=year_s, month_s=month_s, day_s=day_s, sflog=sflog
).aggregate(sum=Sum("val"))
ms_sflog_s.val = sum_val['sum'] if sum_val['sum'] is not None else 0
ms_sflog_s.val_origin = ms_sflog_s.val
ms_sflog_s.save()
myLogger.info("now: {} _recal_from_mpointstat completed: {}".format(datetime.datetime.now(), current_time))
current_time += datetime.timedelta(hours=1)
# 重算 enstat
current_time = start_time
while current_time <= end_time:
year, month, day, hour = current_time.year, current_time.month, current_time.day, current_time.hour
for mgroup in mgroups:
cal_enstat("hour_s", None, mgroup.id, year, month, day, hour, None, None, None, True, cal_attrs)
current_time += datetime.timedelta(hours=1)
myLogger.info("_recal_from_mpointstat completed all")
@shared_task(base=CustomTask) @shared_task(base=CustomTask)
def correct_bill_date(): def correct_bill_date():
""" """

View File

@ -378,10 +378,14 @@ class MpointStatViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListM
重新运行某段时间的enm计算 重新运行某段时间的enm计算
""" """
data = request.data sr = ReCalSerializer(data=request.data)
sr = ReCalSerializer(data=data)
sr.is_valid(raise_exception=True) sr.is_valid(raise_exception=True)
task = cal_mpointstats_duration.delay(data["start_time"], data["end_time"]) data = sr.validated_data
task = cal_mpointstats_duration.delay(
data["start_time"].strftime("%Y-%m-%d %H:%M:%S"),
data["end_time"].strftime("%Y-%m-%d %H:%M:%S"),
mpoint_stat=data.get("mpoint_stat", False)
)
return Response({"task_id": task.task_id}) return Response({"task_id": task.task_id})
@action(methods=["get"], detail=False, perms_map={"get": "*"}) @action(methods=["get"], detail=False, perms_map={"get": "*"})

View File

@ -14,6 +14,7 @@ class Migration(migrations.Migration):
sql=[ sql=[
( (
""" """
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE TABLE public.enp_envdata ( CREATE TABLE public.enp_envdata (
"timex" timestamptz NOT NULL, "timex" timestamptz NOT NULL,
"equipment_id" text NOT NULL, "equipment_id" text NOT NULL,

View File

@ -388,6 +388,8 @@ class TransferSerializer(CustomModelSerializer):
belong_dept_name = serializers.CharField(source='employee.belong_dept.name', read_only=True) belong_dept_name = serializers.CharField(source='employee.belong_dept.name', read_only=True)
new_post_name = serializers.CharField(source="new_post.name", read_only=True) new_post_name = serializers.CharField(source="new_post.name", read_only=True)
original_post_name = serializers.CharField(source="original_post.name", read_only=True) original_post_name = serializers.CharField(source="original_post.name", read_only=True)
new_dept_name = serializers.CharField(source="new_dept.name", read_only=True)
original_dept_name = serializers.CharField(source="original_dept.name", read_only=True)
class Meta: class Meta:
model = EmployeeTransfer model = EmployeeTransfer
fields = '__all__' fields = '__all__'

View File

@ -39,7 +39,7 @@ class HrmService:
Returns: Returns:
_type_: _description_ _type_: _description_
""" """
if not settings.DAHUA_ENABLED: # 如果大华没启用, 直接返回 if not getattr(settings, 'DAHUA_ENABLED', False): # 如果大华没启用, 直接返回
return return
dh_id = ep.third_info.get('dh_id', None) dh_id = ep.third_info.get('dh_id', None)
dh_photo = ep.third_info.get('dh_photo', None) dh_photo = ep.third_info.get('dh_photo', None)

View File

@ -387,20 +387,39 @@ class EmployeeViewSet(CustomModelViewSet):
if not re.match(r'^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$', data['id_number']): if not re.match(r'^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$', data['id_number']):
raise ParseError(f'{row_num}行,身份证号格式不正确') raise ParseError(f'{row_num}行,身份证号格式不正确')
# 查找或更新 # 查找或创建/补全
try: try:
with transaction.atomic(): with transaction.atomic():
obj, created = Employee.objects.update_or_create( # 优先按身份证号匹配,匹配不到再按姓名匹配
id_number=id_number, existing = None
name=name, if id_number:
defaults=data existing = Employee.objects.filter(id_number=id_number).first()
) if not existing and name:
existing = Employee.objects.filter(name=name, id_number__isnull=True).first() or \
Employee.objects.filter(name=name, id_number='').first()
if existing:
# 只用 Excel 非空值填补数据库中为空的字段
updated_fields = []
for field_name, value in data.items():
if value in [None, '']:
continue
current_value = getattr(existing, field_name, None)
if current_value in [None, '']:
setattr(existing, field_name, value)
updated_fields.append(field_name)
if updated_fields:
existing.save(update_fields=updated_fields + ['update_time'])
myLogger.info(f"✅ 第{row_num}行补全成功:{name},更新字段:{updated_fields}")
else:
myLogger.info(f"⏭️ 第{row_num}行无需补全:{name}")
created = False
else:
Employee.objects.create(id_number=id_number, name=name, **data)
created = True
except Exception as e: except Exception as e:
raise raise
if created: if created:
myLogger.info(f"✅ 第{row_num}行新增成功:{name}") myLogger.info(f"✅ 第{row_num}行新增成功:{name}")
else:
myLogger.info(f"✅ 第{row_num}行更新成功:{name}")
success += 1 success += 1

0
apps/ichat/__init__.py Normal file
View File

3
apps/ichat/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
apps/ichat/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.ichat'

View File

@ -0,0 +1,48 @@
# Generated by Django 3.2.12 on 2025-05-21 05:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Conversation',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('title', models.CharField(default='新对话', max_length=200, verbose_name='对话标题')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversation_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversation_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('content', models.TextField(verbose_name='消息内容')),
('role', models.CharField(default='user', help_text='system/user', max_length=10, verbose_name='角色')),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='ichat.conversation', verbose_name='对话')),
],
options={
'abstract': False,
},
),
]

View File

17
apps/ichat/models.py Normal file
View File

@ -0,0 +1,17 @@
from django.db import models
from apps.system.models import CommonADModel, BaseModel
# Create your models here.
class Conversation(CommonADModel):
"""
TN: 对话
"""
title = models.CharField(max_length=200, default='新对话',verbose_name='对话标题')
class Message(BaseModel):
"""
TN: 消息
"""
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages', verbose_name='对话')
content = models.TextField(verbose_name='消息内容')
role = models.CharField("角色", max_length=10, default='user', help_text="system/user")

View File

@ -0,0 +1,14 @@
# 角色
你是一位数据分析专家和前端程序员,具备深厚的专业知识和丰富的实践经验。你能够精准理解用户的文本描述, 并形成报告。
# 技能
1. 仔细分析用户提供的JSON格式数据分析用户需求。
2. 依据得到的需求, 分别获取JSON数据中的关键信息。
3. 根据2中的关键信息最优化选择表格/饼图/柱状图/折线图等格式绘制报告。
# 回答要求
1. 仅生成完整的HTML代码所有功能都需要实现支持响应式不要输出任何解释或说明。
2. 代码中如需要Echarts等js库请直接使用中国大陆的CDN链接例如bootcdn的链接。
3. 标题为 数据分析报告。
3. 在开始部分请以表格形式简略展示获取的JSON数据。
4. 之后选择最合适的图表方式生成相应的图。
5. 在最后提供可下载该报告的完整PDF的按钮和功能。
6. 在最后提供可下载含有JSON数据的EXCEL文件的按钮和功能。

View File

@ -0,0 +1,53 @@
# 角色
你是一位资深的Postgresql数据库SQL专家具备深厚的专业知识和丰富的实践经验。你能够精准理解用户的文本描述并生成准确可执行的SQL语句。
# 技能
1. 仔细分析用户提供的文本描述,明确用户需求。
2. 根据对用户需求的理解生成符合Postgresql数据库语法的准确可执行的SQL语句。
# 回答要求
1. 如果用户的询问未以 查询 开头,请直接回复 "请以 查询 开头,重新描述你的需求"。
2. 生成的SQL语句必须符合Postgresql数据库的语法规范。
3. 不要使用 Markerdown 和 SQL 语法格式输出,禁止添加语法标准、备注、说明等信息。
4. 直接输出符合Postgresql标准的SQL语句用txt纯文本格式展示即可。
5. 如果无法生成符合要求的SQL语句请直接回复 "无法生成"。
# 示例
1. 问:查询 外协白片抛 工段在2025年6月1日到2025年6月15日之间的生产合格数以及合格率等
select
sum(mlog.count_use) as 领用数,
sum(mlog.count_real) as 生产数,
sum(mlog.count_ok) as 合格数,
sum(mlog.count_notok) as 不合格数,
CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率
from wpm_mlog mlog
left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id
where mlog.submit_time is not null
and mgroup.name = '外协白片抛'
and mlog.handle_date >= '2025-06-01'
and mlog.handle_date <= '2025-06-15'
2. 问:查询 黑化 工段在2025年6月的生产合格数以及合格率等
答: select
sum(mlog.count_use) as 领用数,
sum(mlog.count_real) as 生产数,
sum(mlog.count_ok) as 合格数,
sum(mlog.count_notok) as 不合格数,
CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率
from wpm_mlog mlog
left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id
where mlog.submit_time is not null
and mgroup.name = '黑化'
and mlog.handle_date >= '2025-06-01'
and mlog.handle_date <= '2025-06-30'
3. 问:查询 各工段 在2025年6月的生产合格数以及合格率等
答: select
mgroup.name as 工段,
sum(mlog.count_use) as 领用数,
sum(mlog.count_real) as 生产数,
sum(mlog.count_ok) as 合格数,
sum(mlog.count_notok) as 不合格数,
CAST ( SUM ( mlog.count_ok ) AS FLOAT ) / NULLIF ( SUM ( mlog.count_real ), 0 ) * 100 AS 合格率
from wpm_mlog mlog
left join mtm_mgroup mgroup on mgroup.id = mlog.mgroup_id
where mlog.submit_time is not null
and mlog.handle_date >= '2025-06-01'
and mlog.handle_date <= '2025-06-30'
group by mgroup.id
order by mgroup.sort

22
apps/ichat/script.py Normal file
View File

@ -0,0 +1,22 @@
import json
from .models import Message
from django.http import StreamingHttpResponse
def stream_generator(stream_response: bytes, conversation_id: str):
full_content = ''
for chunk in stream_response.iter_content(chunk_size=1024):
if chunk:
full_content += chunk.decode('utf-8')
try:
data = json.loads(full_content)
content = data.get("choices", [{}])[0].get("delta", {}).get("content", "")
Message.objects.create(
conversation_id=conversation_id,
content=content
)
yield f" data:{content}\n\n"
full_content = ''
except json.JSONDecodeError:
continue
return StreamingHttpResponse(stream_generator(stream_response, conversation_id), content_type='text/event-stream')

18
apps/ichat/serializers.py Normal file
View File

@ -0,0 +1,18 @@
from rest_framework import serializers
from .models import Conversation, Message
from apps.utils.constants import EXCLUDE_FIELDS
class MessageSerializer(serializers.ModelSerializer):
class Meta:
model = Message
fields = ['id', 'conversation', 'content', 'role']
read_only_fields = EXCLUDE_FIELDS
class ConversationSerializer(serializers.ModelSerializer):
messages = MessageSerializer(many=True, read_only=True)
class Meta:
model = Conversation
fields = ['id', 'title', 'messages']
read_only_fields = EXCLUDE_FIELDS

3
apps/ichat/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

16
apps/ichat/urls.py Normal file
View File

@ -0,0 +1,16 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.ichat.views import QueryLLMviewSet, ConversationViewSet
from apps.ichat.views2 import WorkChain
API_BASE_URL = 'api/ichat/'
router = DefaultRouter()
router.register('conversation', ConversationViewSet, basename='conversation')
router.register('message', QueryLLMviewSet, basename='message')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
path(API_BASE_URL + 'workchain/ask/', WorkChain.as_view(), name='workchain')
]

195
apps/ichat/utils.py Normal file
View File

@ -0,0 +1,195 @@
import re
import psycopg2
import threading
from django.db import transaction
from .models import Message
# 数据库连接
def connect_db():
from server.conf import DATABASES
db_conf = DATABASES['default']
conn = psycopg2.connect(
host=db_conf['HOST'],
port=db_conf['PORT'],
user=db_conf['USER'],
password=db_conf['PASSWORD'],
database=db_conf['NAME']
)
return conn
def extract_sql_code(text):
# 优先尝试 ```sql 包裹的语句
match = re.search(r"```sql\s*(.+?)```", text, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
# fallback: 寻找首个 select 语句
match = re.search(r"(SELECT\s.+?;)", text, re.IGNORECASE | re.DOTALL)
if match:
return match.group(1).strip()
return None
# def get_schema_text(conn, table_names:list):
# cur = conn.cursor()
# query = """
# SELECT
# table_name, column_name, data_type
# FROM
# information_schema.columns
# WHERE
# table_schema = 'public'
# and table_name in %s;
# """
# cur.execute(query, (tuple(table_names), ))
# schema = {}
# for table_name, column_name, data_type in cur.fetchall():
# if table_name not in schema:
# schema[table_name] = []
# schema[table_name].append(f"{column_name} ({data_type})")
# cur.close()
# schema_text = ""
# for table_name, columns in schema.items():
# schema_text += f"表{table_name} 包含列:{', '.join(columns)}\n"
# return schema_text
def get_schema_text(conn, table_names: list):
cur = conn.cursor()
query = """
SELECT
c.relname AS table_name,
a.attname AS column_name,
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
d.description AS column_comment
FROM
pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
LEFT JOIN pg_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum
WHERE
n.nspname = 'public'
AND c.relname = ANY(%s)
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY
c.relname, a.attnum;
"""
cur.execute(query, (table_names,))
schema = {}
for table_name, column_name, data_type, comment in cur.fetchall():
if comment and "备注" in comment:
comment = comment.split("备注")[0].strip()
schema.setdefault(table_name, []).append(
f"{column_name}-{comment}"
)
cur.close()
return [
{"table": table, "text": f"{table} 包含列:\n" + "\n".join(columns)}
for table, columns in schema.items()
]
# def get_schema_text(conn, table_names: list):
# cur = conn.cursor()
# # 获取字段、类型、注释
# column_query = """
# SELECT
# c.relname AS table_name,
# a.attname AS column_name,
# pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
# d.description AS column_comment
# FROM
# pg_class c
# JOIN pg_namespace n ON n.oid = c.relnamespace
# JOIN pg_attribute a ON a.attrelid = c.oid
# LEFT JOIN pg_description d ON d.objoid = a.attrelid AND d.objsubid = a.attnum
# WHERE
# n.nspname = 'public'
# AND c.relname = ANY(%s)
# AND a.attnum > 0
# AND NOT a.attisdropped
# ORDER BY
# c.relname, a.attnum;
# """
# # 获取外键信息
# fk_query = """
# SELECT
# conrelid::regclass::text AS table_name,
# a.attname AS column_name,
# confrelid::regclass::text AS foreign_table,
# af.attname AS foreign_column
# FROM
# pg_constraint
# JOIN pg_class ON conrelid = pg_class.oid
# JOIN pg_namespace n ON pg_class.relnamespace = n.oid
# JOIN pg_attribute a ON a.attrelid = conrelid AND a.attnum = ANY(conkey)
# JOIN pg_attribute af ON af.attrelid = confrelid AND af.attnum = ANY(confkey)
# WHERE
# contype = 'f'
# AND n.nspname = 'public'
# AND conrelid::regclass::text = ANY(%s);
# """
# cur.execute(column_query, (table_names,))
# columns = cur.fetchall()
# cur.execute(fk_query, (table_names,))
# fks = cur.fetchall()
# # 构建外键字典
# fk_map = {} # {(table, column): "foreign_table(foreign_column)"}
# for table, column, f_table, f_column in fks:
# fk_map[(table, column)] = f"{f_table}({f_column})"
# # 组织输出结构
# schema = {}
# for table, column, dtype, comment in columns:
# fk_note = f" -> {fk_map[(table, column)]}" if (table, column) in fk_map else ""
# comment_note = f" -- {comment}" if comment else ""
# schema.setdefault(table, []).append(f"{column} ({dtype}{fk_note}{comment_note})")
# cur.close()
# # 生成文本
# schema_text = ""
# for table, cols in schema.items():
# schema_text += f"表 {table} 包含列:\n - " + "\n - ".join(cols) + "\n"
# return schema_text
def is_safe_sql(sql:str) -> bool:
sql = sql.strip().lower()
return sql.startswith("select") or sql.startswith("show") and not re.search(r"delete|update|insert|drop|create|alter", sql)
def execute_sql(conn, sql_query):
cur = conn.cursor()
cur.execute(sql_query)
try:
rows = cur.fetchall()
columns = [desc[0] for desc in cur.description]
result = [dict(zip(columns, row)) for row in rows]
except psycopg2.ProgrammingError:
result = cur.statusmessage
cur.close()
return result
def strip_sql_markdown(content: str) -> str:
# 去掉包裹在 ```sql 或 ``` 中的内容
match = re.search(r"```sql\s*(.*?)```", content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
else:
return None
# ORM 写入包装函数
def save_message_thread_safe(**kwargs):
def _save():
with transaction.atomic():
Message.objects.create(**kwargs)
threading.Thread(target=_save).start()

0
apps/ichat/view Normal file
View File

155
apps/ichat/view_bak.py Normal file
View File

@ -0,0 +1,155 @@
import requests
import json
from rest_framework.views import APIView
from apps.ichat.serializers import MessageSerializer, ConversationSerializer
from rest_framework.response import Response
from apps.ichat.models import Conversation, Message
from apps.ichat.utils import connect_db, extract_sql_code, execute_sql, get_schema_text, is_safe_sql, save_message_thread_safe
from django.http import StreamingHttpResponse, JsonResponse
from rest_framework.decorators import action
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
# API_KEY = "sk-5644e2d6077b46b9a04a8a2b12d6b693"
# API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# MODEL = "qwen-plus"
# #本地部署的模式
API_KEY = "JJVAide0hw3eaugGmxecyYYFw45FX2LfhnYJtC+W2rw"
API_BASE = "http://106.0.4.200:9000/v1"
MODEL = "qwen14b"
# google gemini
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="google/gemini-2.0-flash-exp:free"
# deepseek v3
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="deepseek/deepseek-chat-v3-0324:free"
TABLES = ["enm_mpoint", "enm_mpointstat", "enm_mplogx"] # 如果整个数据库全都给模型,准确率下降,所以只给模型部分表
class QueryLLMviewSet(CustomModelViewSet):
queryset = Message.objects.all()
serializer_class = MessageSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}
@action(methods=['post'], detail=False, perms_map={'post':'*'} ,serializer_class=MessageSerializer)
def completion(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
prompt = serializer.validated_data['content']
conversation = serializer.validated_data['conversation']
if not prompt or not conversation:
return JsonResponse({"error": "缺少 prompt 或 conversation"}, status=400)
save_message_thread_safe(content=prompt, conversation=conversation, role="user")
url = f"{API_BASE}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
user_prompt = f"""
我提问的问题是:{prompt}请判断我的问题是否与数据库查询或操作相关如果是回答"database"如果不是回答"general"
注意
只需回答"database""general"即可不要有其他内容
"""
_payload = {
"model": MODEL,
"messages": [{"role": "user", "content": user_prompt}],
"temperature": 0,
"max_tokens": 10
}
try:
class_response = requests.post(url, headers=headers, json=_payload)
class_response.raise_for_status()
class_result = class_response.json()
question_type = class_result.get('choices', [{}])[0].get('message', {}).get('content', '').strip().lower()
print("question_type", question_type)
if question_type == "database":
conn = connect_db()
schema_text = get_schema_text(conn, TABLES)
print("schema_text----------------------", schema_text)
user_prompt = f"""你是一个专业的数据库工程师,根据以下数据库结构:
{schema_text}
请根据我的需求生成一条标准的PostgreSQL SQL语句直接返回SQL不要额外解释
需求是{prompt}
"""
else:
user_prompt = f"""
回答以下问题不需要涉及数据库查询
问题: {prompt}
请直接回答问题不要提及数据库或SQL
"""
# TODO 是否应该拿到conservastion的id然后根据id去数据库查询所以的messages, 然后赋值给messages
# history = Message.objects.filter(conversation=conversation).order_by('create_time')
# chat_history = [{"role": msg.role, "content": msg.content} for msg in history]
# chat_history.append({"role": "user", "content": prompt})
chat_history = [{"role":"user", "content":user_prompt}]
print("chat_history", chat_history)
payload = {
"model": MODEL,
"messages": chat_history,
"temperature": 0,
"stream": True
}
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
except requests.exceptions.RequestException as e:
return JsonResponse({"error":f"LLM API调用失败: {e}"}, status=500)
def stream_generator():
accumulated_content = ""
for line in response.iter_lines():
if line:
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data:'):
if decoded_line.strip() == "data: [DONE]":
break # OpenAI-style标志结束
try:
data = json.loads(decoded_line[6:])
content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
if content:
accumulated_content += content
yield f"data: {content}\n\n"
except Exception as e:
yield f"data: [解析失败]: {str(e)}\n\n"
print("accumulated_content", accumulated_content)
save_message_thread_safe(content=accumulated_content, conversation=conversation, role="system")
if question_type == "database":
sql = extract_sql_code(accumulated_content)
if sql:
try:
conn = connect_db()
if is_safe_sql(sql):
result = execute_sql(conn, sql)
save_message_thread_safe(content=f"SQL结果: {result}", conversation=conversation, role="system")
yield f"data: SQL执行结果: {result}\n\n"
else:
yield f"data: 拒绝执行非查询类 SQL{sql}\n\n"
except Exception as e:
yield f"data: SQL执行失败: {str(e)}\n\n"
finally:
if conn:
conn.close()
else:
yield "data: \\n[文本结束]\n\n"
return StreamingHttpResponse(stream_generator(), content_type='text/event-stream')
# 先新建对话 生成对话session_id
class ConversationViewSet(CustomModelViewSet):
queryset = Conversation.objects.all()
serializer_class = ConversationSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}

286
apps/ichat/view_bak2.py Normal file
View File

@ -0,0 +1,286 @@
import requests
import json
import faiss
import numpy as np
from rest_framework.views import APIView
from apps.ichat.serializers import MessageSerializer, ConversationSerializer
from rest_framework.response import Response
from apps.ichat.models import Conversation, Message
from apps.ichat.utils import connect_db, extract_sql_code, execute_sql, is_safe_sql, save_message_thread_safe, get_table_structures
from django.http import StreamingHttpResponse, JsonResponse
from rest_framework.decorators import action
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
# API_KEY = "sk-5644e2d6077b46b9a04a8a2b12d6b693"
# API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# MODEL = "qwen-plus"
#本地部署的模式
API_KEY = "JJVAide0hw3eaugGmxecyYYFw45FX2LfhnYJtC+W2rw"
API_BASE = "http://106.0.4.200:9000/v1"
MODEL = "qwen14b"
# 文本向量化模型
EM_MODEL = "m3e-base"
API_BASE_EM = "http://106.0.4.200:9997/v1"
# google gemini
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="google/gemini-2.0-flash-exp:free"
# deepseek v3
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="deepseek/deepseek-chat-v3-0324:free"
TABLES = ["enm_mpoint", "enm_mpointstat", "enm_mplogx"] # 如果整个数据库全都给模型,准确率下降,所以只给模型部分表
HEADERS = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
def get_table_names(conn):
sql = """
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public';
"""
cur = conn.cursor()
cur.execute(sql)
data = cur.fetchall()
cur.close()
return [row[0] for row in data]
# def get_relation_table(query):
# conn = connect_db()
# # table_names = TABLES
# table_names = get_table_names(conn)
# schemas = get_table_structures(conn, table_names)
# texts = [
# f"这是一个数据库表结构,表名为 {s['table']},其结构如下:{s['text']}"
# for s in schemas
# ]
# table_names = [s["table"] for s in schemas]
# embeddings = embed_text(texts)
# index, index_table_map = create_index(embeddings, texts, table_names)
# results = search_similar_tables(query, index, index_table_map, top_k=3)
# if not results:
# return "没有找到相关表结构"
# return results
def get_relation_table(query: str):
conn = connect_db()
table_names = get_table_names(conn) # 只获取用户表
schemas = get_table_structures(conn, table_names)
texts = [s["text"] for s in schemas]
table_names = [s["table"] for s in schemas]
embeddings = embed_text(texts)
# 存储向量
store_embeddings_pg(conn, embeddings, texts, table_names)
# 查询相似表
results = search_similar_tables_pg(conn, query, top_k=5)
if len(results) == 0:
return "没有找到相关表结构"
# 只取相关表的结构
schemas = get_table_structures(conn, results)
llm_results = format_schema_for_llm(schemas)
return llm_results
def store_embeddings_pg(conn, embeddings: list[list[float]], texts: list[str], table_names: list[str]):
cur = conn.cursor()
for embedding, text, table_name in zip(embeddings, texts, table_names):
cur.execute("""
INSERT INTO table_embeddings (table_name, schema_text, embedding)
VALUES (%s, %s, %s)
ON CONFLICT (table_name) DO UPDATE
SET schema_text = EXCLUDED.schema_text,
embedding = EXCLUDED.embedding
""", (table_name, text, embedding))
conn.commit()
cur.close()
def search_similar_tables_pg(conn, query: str, top_k: int = 5):
# 第一步:将 query 转为 embedding
query_embedding = embed_text([query])[0]
# 第二步embedding 转成 '[x, y, z]' 格式字符串
embedding_str = ",".join(map(str, query_embedding))
cur = conn.cursor()
query = f"""
SELECT table_name
FROM table_embeddings
ORDER BY embedding <-> '[{embedding_str}]'::vector
LIMIT {top_k};
"""
cur.execute(query)
results = [row[0] for row in cur.fetchall()]
cur.close()
return results
def format_schema_for_llm(schemas: list[dict]) -> str:
lines = []
for schema in schemas:
lines.append(f"【表名】:{schema['table']}")
lines.append("【字段】:")
for col in schema["text"].split("结构如下:")[1].split("\n"):
if col.strip():
lines.append(f" - {col.strip()}")
lines.append("") # 空行分隔表
return "\n".join(lines)
def embed_text(texts: list[str]) -> list[list[float]]:
paylaod = {
"input":texts,
"model":EM_MODEL
}
url = f"{API_BASE_EM}/embeddings"
response = requests.post(url, headers=HEADERS, json=paylaod)
json_data = response.json()
return [e['embedding'] for e in json_data['data']]
# def search_similar_tables(query: str, index, index_table_map, top_k:int=3):
# query_embedding = embed_text([query])[0]
# distances, indices = index.search(np.array([query_embedding]).astype("float32"), int(top_k))
# results = []
# for i in indices[0]:
# if i != -1 and i in index_table_map:
# results.append(index_table_map[i])
# return results
# def create_index(embeddings: list[list[float]], texts: list[str], table_names: list[str]):
# print(len(embeddings), '-----------')
# dim = len(embeddings[0])
# index = faiss.IndexFlatL2(dim)
# embeddings_np = np.array(embeddings).astype('float32')
# index.add(embeddings_np)
# # 构建索引到表名的映射字典
# index_table_map = {i: table_names[i] for i in range(len(table_names))}
# return index, index_table_map
class QueryLLMviewSet(CustomModelViewSet):
queryset = Message.objects.all()
serializer_class = MessageSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}
@action(methods=['post'], detail=False, perms_map={'post':'*'} ,serializer_class=MessageSerializer)
def completion(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
prompt = serializer.validated_data['content']
conversation = serializer.validated_data['conversation']
if not prompt or not conversation:
return JsonResponse({"error": "缺少 prompt 或 conversation"}, status=400)
save_message_thread_safe(content=prompt, conversation=conversation, role="user")
url = f"{API_BASE}/chat/completions"
user_prompt = f"""
我提问的问题是:{prompt}请判断我的问题是否与数据库查询或操作相关如果是回答"database"如果不是回答"general"
注意
只需回答"database""general"即可不要有其他内容
"""
_payload = {
"model": MODEL,
"messages": [{"role": "user", "content": user_prompt}],
"temperature": 0,
"max_tokens": 10
}
try:
class_response = requests.post(url, headers=HEADERS, json=_payload)
class_response.raise_for_status()
class_result = class_response.json()
question_type = class_result.get('choices', [{}])[0].get('message', {}).get('content', '').strip().lower()
print("question_type", question_type)
if question_type == "database":
schema_text = get_relation_table(prompt)
user_prompt = f"""你是一个专业的数据库工程师,根据以下数据库结构:
{schema_text}
请根据我的需求生成一条标准的PostgreSQL SQL语句直接返回SQL不要额外解释
需求是{prompt}
"""
else:
user_prompt = f"""
回答以下问题不需要涉及数据库查询
问题: {prompt}
请直接回答问题不要提及数据库或SQL
"""
# TODO 是否应该拿到conservastion的id然后根据id去数据库查询所以的messages, 然后赋值给messages
# history = Message.objects.filter(conversation=conversation).order_by('create_time')
# chat_history = [{"role": msg.role, "content": msg.content} for msg in history]
# chat_history.append({"role": "user", "content": prompt})
chat_history = [{"role":"user", "content":user_prompt}]
print("user_prompt", user_prompt)
payload = {
"model": MODEL,
"messages": chat_history,
"temperature": 0,
"stream": True
}
response = requests.post(url, headers=HEADERS, json=payload)
response.raise_for_status()
except requests.exceptions.RequestException as e:
return JsonResponse({"error":f"LLM API调用失败: {e}"}, status=500)
def stream_generator():
accumulated_content = ""
for line in response.iter_lines():
if line:
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data:'):
if decoded_line.strip() == "data: [DONE]":
break # OpenAI-style标志结束
try:
data = json.loads(decoded_line[6:])
content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
if content:
accumulated_content += content
yield f"data: {content}\n\n"
except Exception as e:
yield f"data: [解析失败]: {str(e)}\n\n"
print("accumulated_content", accumulated_content)
save_message_thread_safe(content=accumulated_content, conversation=conversation, role="system")
if question_type == "database":
sql = extract_sql_code(accumulated_content)
if sql:
try:
conn = connect_db()
if is_safe_sql(sql):
result = execute_sql(conn, sql)
save_message_thread_safe(content=f"SQL结果: {result}", conversation=conversation, role="system")
yield f"data: SQL执行结果: {result}\n\n"
else:
yield f"data: 拒绝执行非查询类 SQL{sql}\n\n"
except Exception as e:
yield f"data: SQL执行失败: {str(e)}\n\n"
finally:
if conn:
conn.close()
else:
yield "data: \\n[文本结束]\n\n"
return StreamingHttpResponse(stream_generator(), content_type='text/event-stream')
# 先新建对话 生成对话session_id
class ConversationViewSet(CustomModelViewSet):
queryset = Conversation.objects.all()
serializer_class = ConversationSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}

214
apps/ichat/views.py Normal file
View File

@ -0,0 +1,214 @@
import requests
import json
import faiss
import numpy as np
from rest_framework.views import APIView
from apps.ichat.serializers import MessageSerializer, ConversationSerializer
from rest_framework.response import Response
from apps.ichat.models import Conversation, Message
from apps.ichat.utils import connect_db, extract_sql_code, execute_sql, get_schema_text, is_safe_sql, save_message_thread_safe
from django.http import StreamingHttpResponse, JsonResponse
from rest_framework.decorators import action
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
# API_KEY = "sk-5644e2d6077b46b9a04a8a2b12d6b693"
# API_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# MODEL = "qwen-plus"
#本地部署的模式
API_KEY = "JJVAide0hw3eaugGmxecyYYFw45FX2LfhnYJtC+W2rw"
API_BASE = "http://106.0.4.200:9000/v1"
MODEL = "qwen14b"
# 文本向量化模型
EM_MODEL = "m3e-base"
# google gemini
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="google/gemini-2.0-flash-exp:free"
# deepseek v3
# API_KEY = "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"
# API_BASE = "https://openrouter.ai/api/v1"
# MODEL="deepseek/deepseek-chat-v3-0324:free"
TABLES = ["enm_mpoint", "enm_mpointstat", "enm_mplogx"] # 如果整个数据库全都给模型,准确率下降,所以只给模型部分表
HEADERS = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
# 表结构向量化
def embed_text(texts: list[str]) -> list[list[float]]:
url = f"{API_BASE}/embeddings"
_payload = {
"model": EM_MODEL,
"input": texts
}
try:
response = requests.post(url, headers=HEADERS, json=_payload)
except requests.exceptions.RequestException as e:
return JsonResponse({"error":f"Embedding API调用失败: {e}"}, status=500)
print("embeddings", response["data"])
return [e['embedding'] for e in response['data']]
# 创建Faiss索引
def create_index(embeddings: list[list[float]], texts: list[str], table_names: list[str]):
index = faiss.IndexFlatL2(len(embeddings[0]))
index.add(np.array(embeddings)).astype("float32")
index_table_map = {i: {"table": table_names[i], "text": texts[i]} for i in range(len(table_names))}
return index, index_table_map
# 查询
def search_similar_tables(query:str, index, index_table_map, k:int=5):
query_embedding = embed_text([query])[0]
distances, indices = index.search(np.array([query_embedding]).astype("float32"), k)
return [index_table_map[i] for i in indices[0]]
def get_tables(conn) -> list[str]:
with conn.cursor() as cur:
cur.execute("""
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tableowner = 'postgres';
""")
return [row[0] for row in cur.fetchall()]
# 主函数:提取表结构、嵌入向量并存储到 FAISS
def get_relation_table(query):
conn = connect_db()
table_names = get_tables(conn)
schemas = get_schema_text(conn, table_names)
texts = [s["text"] for s in schemas]
# table_names = [s["table"] for s in schemas]
embeddings = embed_text(texts)
index, index_table_map = create_index(embeddings, texts, table_names)
results = search_similar_tables(query, index, index_table_map)
for result in results:
print(f"表名: {result['table']}\n结构: {result['text']}")
if len(results) == 0:
return "没有找到相关表结构"
return results
class QueryLLMviewSet(CustomModelViewSet):
queryset = Message.objects.all()
serializer_class = MessageSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}
@action(methods=['post'], detail=False, perms_map={'post':'*'} ,serializer_class=MessageSerializer)
def completion(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
prompt = serializer.validated_data['content']
conversation = serializer.validated_data['conversation']
if not prompt or not conversation:
return JsonResponse({"error": "缺少 prompt 或 conversation"}, status=400)
save_message_thread_safe(content=prompt, conversation=conversation, role="user")
url = f"{API_BASE}/chat/completions"
user_prompt = f"""
我提问的问题是:{prompt}请判断我的问题是否与数据库查询或操作相关如果是回答"database"如果不是回答"general"
注意
只需回答"database""general"即可不要有其他内容
"""
_payload = {
"model": MODEL,
"messages": [{"role": "user", "content": user_prompt}],
"temperature": 0,
"max_tokens": 10
}
try:
class_response = requests.post(url, headers=HEADERS, json=_payload)
class_response.raise_for_status()
class_result = class_response.json()
question_type = class_result.get('choices', [{}])[0].get('message', {}).get('content', '').strip().lower()
print("question_type", question_type)
if question_type == "database":
schema_text = get_relation_table(prompt)
print("schema_text----------------------", schema_text)
user_prompt = f"""你是一个专业的数据库工程师,根据以下数据库结构:
{schema_text}
请根据我的需求生成一条标准的PostgreSQL SQL语句直接返回SQL不要额外解释
需求是{prompt}
"""
else:
user_prompt = f"""
回答以下问题不需要涉及数据库查询
问题: {prompt}
请直接回答问题不要提及数据库或SQL
"""
# TODO 是否应该拿到conservastion的id然后根据id去数据库查询所以的messages, 然后赋值给messages
# history = Message.objects.filter(conversation=conversation).order_by('create_time')
# chat_history = [{"role": msg.role, "content": msg.content} for msg in history]
# chat_history.append({"role": "user", "content": prompt})
chat_history = [{"role":"user", "content":user_prompt}]
print("chat_history", chat_history)
payload = {
"model": MODEL,
"messages": chat_history,
"temperature": 0,
"stream": True
}
response = requests.post(url, headers=HEADERS, json=payload)
response.raise_for_status()
except requests.exceptions.RequestException as e:
return JsonResponse({"error":f"LLM API调用失败: {e}"}, status=500)
def stream_generator():
accumulated_content = ""
for line in response.iter_lines():
if line:
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data:'):
if decoded_line.strip() == "data: [DONE]":
break # OpenAI-style标志结束
try:
data = json.loads(decoded_line[6:])
content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
if content:
accumulated_content += content
yield f"data: {content}\n\n"
except Exception as e:
yield f"data: [解析失败]: {str(e)}\n\n"
print("accumulated_content", accumulated_content)
save_message_thread_safe(content=accumulated_content, conversation=conversation, role="system")
if question_type == "database":
sql = extract_sql_code(accumulated_content)
if sql:
try:
conn = connect_db()
if is_safe_sql(sql):
result = execute_sql(conn, sql)
save_message_thread_safe(content=f"SQL结果: {result}", conversation=conversation, role="system")
yield f"data: SQL执行结果: {result}\n\n"
else:
yield f"data: 拒绝执行非查询类 SQL{sql}\n\n"
except Exception as e:
yield f"data: SQL执行失败: {str(e)}\n\n"
finally:
if conn:
conn.close()
else:
yield "data: \\n[文本结束]\n\n"
return StreamingHttpResponse(stream_generator(), content_type='text/event-stream')
# 先新建对话 生成对话session_id
class ConversationViewSet(CustomModelViewSet):
queryset = Conversation.objects.all()
serializer_class = ConversationSerializer
ordering = ['create_time']
perms_map = {'get':'*', 'post':'*', 'put':'*'}

129
apps/ichat/views2.py Normal file
View File

@ -0,0 +1,129 @@
import requests
import os
from apps.utils.sql import execute_raw_sql
import json
from apps.utils.tools import MyJSONEncoder
from .utils import is_safe_sql
from rest_framework.views import APIView
from drf_yasg.utils import swagger_auto_schema
from rest_framework import serializers
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from django.conf import settings
from apps.utils.mixins import MyLoggingMixin
from django.core.cache import cache
import uuid
from apps.utils.thread import MyThread
LLM_URL = getattr(settings, "LLM_URL", "")
API_KEY = getattr(settings, "LLM_API_KEY", "")
MODEL = "qwen14b"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
def load_promot(name):
with open(os.path.join(CUR_DIR, f'promot/{name}.md'), 'r') as f:
return f.read()
def ask(input:str, p_name:str, stream=False):
his = [{"role":"system", "content": load_promot(p_name)}]
his.append({"role":"user", "content": input})
payload = {
"model": MODEL,
"messages": his,
"temperature": 0,
"stream": stream
}
response = requests.post(LLM_URL, headers=HEADERS, json=payload, stream=stream)
if not stream:
return response.json()["choices"][0]["message"]["content"]
else:
# 处理流式响应
full_content = ""
for chunk in response.iter_lines():
if chunk:
# 通常流式响应是SSE格式data: {...}
decoded_chunk = chunk.decode('utf-8')
if decoded_chunk.startswith("data:"):
json_str = decoded_chunk[5:].strip()
if json_str == "[DONE]":
break
try:
chunk_data = json.loads(json_str)
if "choices" in chunk_data and chunk_data["choices"]:
delta = chunk_data["choices"][0].get("delta", {})
if "content" in delta:
print(delta["content"])
full_content += delta["content"]
except json.JSONDecodeError:
continue
return full_content
def work_chain(input:str, t_key:str):
pdict = {"state": "progress", "steps": [{"state":"ok", "msg":"正在生成查询语句"}]}
cache.set(t_key, pdict)
res_text = ask(input, 'w_sql')
if res_text == '请以 查询 开头,重新描述你的需求':
pdict["state"] = "error"
pdict["steps"].append({"state":"error", "msg":res_text})
cache.set(t_key, pdict)
return
else:
pdict["steps"].append({"state":"ok", "msg":"查询语句生成成功", "content":res_text})
cache.set(t_key, pdict)
if not is_safe_sql(res_text):
pdict["state"] = "error"
pdict["steps"].append({"state":"error", "msg":"当前查询存在风险,请重新描述你的需求"})
cache.set(t_key, pdict)
return
pdict["steps"].append({"state":"ok", "msg":"正在执行查询语句"})
cache.set(t_key, pdict)
res = execute_raw_sql(res_text)
pdict["steps"].append({"state":"ok", "msg":"查询语句执行成功", "content":res})
cache.set(t_key, pdict)
pdict["steps"].append({"state":"ok", "msg":"正在生成报告"})
cache.set(t_key, pdict)
res2 = ask(json.dumps(res, cls=MyJSONEncoder, ensure_ascii=False), 'w_ana')
content = res2.lstrip('```html ').rstrip('```')
pdict["state"] = "done"
pdict["content"] = content
pdict["steps"].append({"state":"ok", "msg":"报告生成成功", "content": content})
cache.set(t_key, pdict)
return
class InputSerializer(serializers.Serializer):
input = serializers.CharField(label="查询需求")
class WorkChain(MyLoggingMixin, APIView):
@swagger_auto_schema(
operation_summary="提交查询需求",
request_body=InputSerializer)
def post(self, request):
llm_enabled = getattr(settings, "LLM_ENABLED", False)
if not llm_enabled:
raise ParseError('LLM功能未启用')
input = request.data.get('input')
t_key = f'ichat_{uuid.uuid4()}'
MyThread(target=work_chain, args=(input, t_key)).start()
return Response({'ichat_tid': t_key})
@swagger_auto_schema(
operation_summary="获取查询进度")
def get(self, request):
llm_enabled = getattr(settings, "LLM_ENABLED", False)
if not llm_enabled:
raise ParseError('LLM功能未启用')
ichat_tid = request.GET.get('ichat_tid')
if ichat_tid:
return Response(cache.get(ichat_tid))
if __name__ == "__main__":
print(work_chain("查询 一次超洗 工段在2025年6月的生产合格数等并形成报告"))
from apps.ichat.views2 import work_chain
print(work_chain('查询外观检验工段在2025年6月的生产合格数等并形成报告'))

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.12 on 2026-03-12 03:26 # Generated by Django 4.2.27 on 2026-04-24 06:50
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@ -12,11 +12,31 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'), ('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wf', '0006_auto_20251215_1645'), ('wf', '0006_auto_20251215_1645'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel(
name='MaterialRequisition',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('number', models.CharField(max_length=20, unique=True, verbose_name='编号')),
('req_date', models.DateField(blank=True, null=True, verbose_name='填报时间')),
('collector', models.CharField(blank=True, max_length=50, null=True, verbose_name='领取人')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='material_requisition_ticket', to='wf.ticket', verbose_name='关联工单')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel( migrations.CreateModel(
name='PurchaseRequisition', name='PurchaseRequisition',
fields=[ fields=[
@ -29,10 +49,100 @@ class Migration(migrations.Migration):
('req_date', models.DateField(blank=True, null=True, verbose_name='申购日期')), ('req_date', models.DateField(blank=True, null=True, verbose_name='申购日期')),
('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')), ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')), ('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_belong_dept', to='system.dept', verbose_name='所属部门')), ('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), ('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_ticket', to='wf.ticket', verbose_name='关联工单')), ('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_ticket', to='wf.ticket', verbose_name='关联工单')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchaserequisition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')), ('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WareHouse',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('number', models.CharField(max_length=20, verbose_name='库房编号')),
('name', models.CharField(max_length=20, verbose_name='库房名称')),
('place', models.CharField(max_length=50, verbose_name='具体地点')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_warehouse_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_warehouse_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mpr_warehouse_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WarehouseEntry',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('number', models.CharField(max_length=20, unique=True, verbose_name='编号')),
('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')),
('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], default='raw_normal', max_length=20, verbose_name='入库类型')),
('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], default='purchase', max_length=20, verbose_name='入库方式')),
('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouse_entry_ticket', to='wf.ticket', verbose_name='关联工单')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='mpr.warehouse', verbose_name='库房')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WarehouseStock',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('entry_number', models.CharField(max_length=20, verbose_name='入库单号')),
('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')),
('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], max_length=20, verbose_name='入库类型')),
('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], max_length=20, verbose_name='入库方式')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')),
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')),
('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')),
('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')),
('status', models.CharField(choices=[('idle', '闲置'), ('in_requisition', '领用中'), ('requisitioned', '已领用')], default='idle', max_length=20, verbose_name='状态')),
('entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocks', to='mpr.warehouseentry', verbose_name='来源入库单')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mpr_stocks', to='mpr.warehouse', verbose_name='库房')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WarehouseEntryItem',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')),
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')),
('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')),
('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.warehouseentry', verbose_name='关联入库单')),
], ],
options={ options={
'abstract': False, 'abstract': False,
@ -61,4 +171,25 @@ class Migration(migrations.Migration):
'abstract': False, 'abstract': False,
}, },
), ),
migrations.CreateModel(
name='MaterialRequisitionItem',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('is_stock_item', models.BooleanField(default=True, verbose_name='是否库存物品')),
('req_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='领用类型')),
('name', models.CharField(max_length=100, verbose_name='物资名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格型号')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='领用量')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.materialrequisition', verbose_name='关联领用单')),
('stock', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requisition_items', to='mpr.warehousestock', verbose_name='关联库存')),
],
options={
'abstract': False,
},
),
] ]

View File

@ -1,65 +0,0 @@
# Generated by Django 3.2.12 on 2026-03-12 06:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
('wf', '0006_auto_20251215_1645'),
('inm', '0038_mioitem_count_send'),
('mpr', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='WarehouseEntry',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('number', models.CharField(max_length=20, unique=True, verbose_name='编号')),
('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')),
('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], default='raw_normal', max_length=20, verbose_name='入库类型')),
('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], default='purchase', max_length=20, verbose_name='入库方式')),
('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合计金额')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouse_entry_ticket', to='wf.ticket', verbose_name='关联工单')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='warehouseentry_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='inm.warehouse', verbose_name='仓库')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='WarehouseEntryItem',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')),
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')),
('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')),
('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.warehouseentry', verbose_name='关联入库单')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,42 +0,0 @@
# Generated by Django 3.2.12 on 2026-03-12 07:26
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('inm', '0038_mioitem_count_send'),
('mpr', '0002_warehouseentry_warehouseentryitem'),
]
operations = [
migrations.CreateModel(
name='WarehouseStock',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('entry_number', models.CharField(max_length=20, verbose_name='入库单号')),
('entry_date', models.DateField(blank=True, null=True, verbose_name='入库日期')),
('entry_type', models.CharField(choices=[('raw_normal', '原材料正常入库'), ('raw_estimated', '原材料暂估入库'), ('product', '产品入库'), ('other', '其他')], max_length=20, verbose_name='入库类型')),
('entry_method', models.CharField(choices=[('purchase', '采购'), ('self_made', '自制'), ('other', '其他')], max_length=20, verbose_name='入库方式')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='数量')),
('unit_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='单价')),
('amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='金额')),
('supplier_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='供应商名称')),
('invoice_received', models.BooleanField(default=False, verbose_name='账单是否收到')),
('entry', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stocks', to='mpr.warehouseentry', verbose_name='来源入库单')),
('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mpr_stocks', to='inm.warehouse', verbose_name='仓库')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,60 +0,0 @@
# Generated by Django 3.2.12 on 2026-03-12 08:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
('wf', '0006_auto_20251215_1645'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('mpr', '0003_warehousestock'),
]
operations = [
migrations.CreateModel(
name='MaterialRequisition',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('number', models.CharField(max_length=20, unique=True, verbose_name='编号')),
('req_date', models.DateField(blank=True, null=True, verbose_name='填报时间')),
('collector', models.CharField(blank=True, max_length=50, null=True, verbose_name='领取人')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('ticket', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='material_requisition_ticket', to='wf.ticket', verbose_name='关联工单')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='materialrequisition_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='MaterialRequisitionItem',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('is_stock_item', models.BooleanField(default=True, verbose_name='是否库存物品')),
('req_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='领用类型')),
('name', models.CharField(max_length=100, verbose_name='物资名称')),
('spec', models.CharField(blank=True, max_length=200, null=True, verbose_name='规格型号')),
('unit', models.CharField(blank=True, max_length=20, null=True, verbose_name='单位')),
('quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='领用量')),
('note', models.TextField(blank=True, null=True, verbose_name='备注')),
('requisition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='mpr.materialrequisition', verbose_name='关联领用单')),
('stock', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requisition_items', to='mpr.warehousestock', verbose_name='关联库存')),
],
options={
'abstract': False,
},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.12 on 2026-03-12 08:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mpr', '0004_materialrequisition_materialrequisitionitem'),
]
operations = [
migrations.AddField(
model_name='warehousestock',
name='status',
field=models.CharField(choices=[('idle', '闲置'), ('in_requisition', '领用中'), ('requisitioned', '已领用')], default='idle', max_length=20, verbose_name='状态'),
),
]

View File

@ -1,9 +1,27 @@
from django.db import models from django.db import models
from apps.utils.models import BaseModel, CommonBDModel from apps.utils.models import BaseModel, CommonBDModel, CommonBModel
from datetime import datetime from datetime import datetime
from django.db.models import Max, Sum from django.db.models import Max, Sum
class WareHouse(CommonBModel):
"""
TN:库房信息
"""
number = models.CharField('库房编号', max_length=20)
name = models.CharField('库房名称', max_length=20)
place = models.CharField('具体地点', max_length=50)
create_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='创建人', related_name='mpr_warehouse_create_by')
update_by = models.ForeignKey(
'system.user', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='最后编辑人', related_name='mpr_warehouse_update_by')
belong_dept = models.ForeignKey(
'system.dept', null=True, blank=True, on_delete=models.SET_NULL,
verbose_name='所属部门', related_name='mpr_warehouse_belong_dept')
def _get_number(model_cls): def _get_number(model_cls):
today_str = datetime.now().strftime('%Y%m%d') today_str = datetime.now().strftime('%Y%m%d')
prefix = model_cls.PREFIX prefix = model_cls.PREFIX
@ -77,7 +95,7 @@ class WarehouseEntry(CommonBDModel):
number = models.CharField('编号', max_length=20, unique=True) number = models.CharField('编号', max_length=20, unique=True)
warehouse = models.ForeignKey( warehouse = models.ForeignKey(
'inm.WareHouse', verbose_name='', WareHouse, verbose_name='',
on_delete=models.CASCADE, related_name='entries') on_delete=models.CASCADE, related_name='entries')
entry_date = models.DateField('入库日期', null=True, blank=True) entry_date = models.DateField('入库日期', null=True, blank=True)
entry_type = models.CharField('入库类型', max_length=20, choices=ENTRY_TYPE_CHOICES, default='raw_normal') entry_type = models.CharField('入库类型', max_length=20, choices=ENTRY_TYPE_CHOICES, default='raw_normal')
@ -123,7 +141,7 @@ class WarehouseStock(BaseModel):
) )
warehouse = models.ForeignKey( warehouse = models.ForeignKey(
'inm.WareHouse', verbose_name='', WareHouse, verbose_name='',
on_delete=models.CASCADE, related_name='mpr_stocks') on_delete=models.CASCADE, related_name='mpr_stocks')
entry = models.ForeignKey( entry = models.ForeignKey(
WarehouseEntry, verbose_name='来源入库单', WarehouseEntry, verbose_name='来源入库单',

View File

@ -7,10 +7,20 @@ from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem, PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock, WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem, MaterialRequisition, MaterialRequisitionItem,
WareHouse,
) )
from apps.wf.serializers import TicketSimpleSerializer from apps.wf.serializers import TicketSimpleSerializer
# ========== 库房 ==========
class WareHouseSerializer(CustomModelSerializer):
class Meta:
model = WareHouse
fields = '__all__'
read_only_fields = ['create_time', 'update_time', 'is_deleted']
# ========== 物资申购单 ========== # ========== 物资申购单 ==========
class PurchaseRequisitionItemSerializer(CustomModelSerializer): class PurchaseRequisitionItemSerializer(CustomModelSerializer):

View File

@ -1,6 +1,7 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.mpr.views import ( from apps.mpr.views import (
WareHouseViewSet,
PurchaseRequisitionViewSet, PurchaseRequisitionItemViewSet, PurchaseRequisitionViewSet, PurchaseRequisitionItemViewSet,
WarehouseEntryViewSet, WarehouseEntryItemViewSet, WarehouseEntryViewSet, WarehouseEntryItemViewSet,
WarehouseStockViewSet, WarehouseStockViewSet,
@ -10,6 +11,7 @@ from apps.mpr.views import (
API_BASE_URL = 'api/mpr/' API_BASE_URL = 'api/mpr/'
router = DefaultRouter() router = DefaultRouter()
router.register('warehouse', WareHouseViewSet, basename='mpr_warehouse')
router.register('requisition', PurchaseRequisitionViewSet, basename='requisition') router.register('requisition', PurchaseRequisitionViewSet, basename='requisition')
router.register('requisition_item', PurchaseRequisitionItemViewSet, basename='requisition_item') router.register('requisition_item', PurchaseRequisitionItemViewSet, basename='requisition_item')
router.register('warehouse_entry', WarehouseEntryViewSet, basename='warehouse_entry') router.register('warehouse_entry', WarehouseEntryViewSet, basename='warehouse_entry')

View File

@ -8,8 +8,10 @@ from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem, PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock, WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem, MaterialRequisition, MaterialRequisitionItem,
WareHouse,
) )
from apps.mpr.serializers import ( from apps.mpr.serializers import (
WareHouseSerializer,
PurchaseRequisitionListSerializer, PurchaseRequisitionListSerializer,
PurchaseRequisitionDetailSerializer, PurchaseRequisitionDetailSerializer,
PurchaseRequisitionCreateSerializer, PurchaseRequisitionCreateSerializer,
@ -30,6 +32,20 @@ from apps.mpr.filters import (
) )
class WareHouseViewSet(CustomModelViewSet):
"""
库房管理
"""
queryset = WareHouse.objects.all()
serializer_class = WareHouseSerializer
search_fields = ['number', 'name', 'place']
ordering = '-create_time'
perms_map = {
'get': '*', 'post': 'warehouse.create',
'put': 'warehouse.update', 'delete': 'warehouse.delete',
}
class PurchaseRequisitionViewSet(TicketMixin, CustomModelViewSet): class PurchaseRequisitionViewSet(TicketMixin, CustomModelViewSet):
""" """
物资申购单 物资申购单

View File

@ -1,12 +1,13 @@
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
from apps.mtm.models import Goal, Material, Route, RoutePack from apps.mtm.models import Goal, Material, Route, RouteMat, RoutePack, Process
from django.db.models.expressions import F from django.db.models.expressions import F
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
class MaterialFilter(filters.FilterSet): class MaterialFilter(filters.FilterSet):
tag = filters.CharFilter(method='filter_tag', label="low_inm:库存不足") tag = filters.CharFilter(method='filter_tag', label="low_inm:库存不足;todo:可用")
process_todo = filters.CharFilter(method='filter_process_todo', label="process_todo:待处理")
class Meta: class Meta:
model = Material model = Material
@ -26,6 +27,14 @@ class MaterialFilter(filters.FilterSet):
"count_safe": ["gte", "lte", "exact", "gt", "lt"] "count_safe": ["gte", "lte", "exact", "gt", "lt"]
} }
def filter_process_todo(self, queryset, name, value):
if value:
queryset = queryset.filter(
Q(id__in=RouteMat.objects.filter(route__process__id=value).values('material_id')) |
Q(id__in=Route.objects.filter(process__id=value).values('material_in_id'))
)
return queryset
def filter_tag(self, queryset, name, value): def filter_tag(self, queryset, name, value):
if value == 'low_inm': if value == 'low_inm':
queryset = Material.annotate_count(queryset.exclude(count_safe=None).exclude(count_safe__lte=0)).filter( queryset = Material.annotate_count(queryset.exclude(count_safe=None).exclude(count_safe__lte=0)).filter(

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ofm', '0005_alter_vehicleuse_end_km'),
]
operations = [
migrations.AddField(
model_name='publicity',
name='final_file',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='终版文件路径'),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ofm', '0006_publicity_final_file'),
]
operations = [
migrations.AddField(
model_name='lendingseal',
name='final_file',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='终版文件路径'),
),
]

View File

@ -101,6 +101,7 @@ class LendingSeal(CommonBDModel):
return_date = models.DateField('拟归还日期', blank=True, null=True) return_date = models.DateField('拟归还日期', blank=True, null=True)
actual_return_date = models.DateField('实际归还日期', blank=True, null=True) actual_return_date = models.DateField('实际归还日期', blank=True, null=True)
reason = models.CharField('借用理由', max_length=100, blank=True, null=True) reason = models.CharField('借用理由', max_length=100, blank=True, null=True)
final_file = models.CharField('终版文件路径', max_length=200, null=True, blank=True)
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单', ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
on_delete=models.SET_NULL, related_name='seal_ticket', null=True, blank=True, db_constraint=False) on_delete=models.SET_NULL, related_name='seal_ticket', null=True, blank=True, db_constraint=False)
note = models.TextField('备注', null=True, blank=True) note = models.TextField('备注', null=True, blank=True)
@ -151,6 +152,7 @@ class Publicity(CommonBDModel):
secret_period = models.CharField('秘密期限', max_length=50, blank=True, null=True) secret_period = models.CharField('秘密期限', max_length=50, blank=True, null=True)
dept_opinion_review = models.CharField('部门审查意见', max_length=100, blank=True, null=True) dept_opinion_review = models.CharField('部门审查意见', max_length=100, blank=True, null=True)
publicity_opinion = models.CharField('宣传报道意见', max_length=100, blank=True, null=True) publicity_opinion = models.CharField('宣传报道意见', max_length=100, blank=True, null=True)
final_file = models.CharField('终版文件路径', max_length=200, null=True, blank=True)
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单', ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
on_delete=models.SET_NULL, related_name='publicity_ticket', null=True, blank=True, db_constraint=False) on_delete=models.SET_NULL, related_name='publicity_ticket', null=True, blank=True, db_constraint=False)

View File

@ -0,0 +1,164 @@
# Generated by Django 4.2.27 on 2026-04-20 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pum', '0010_quotationapply'),
]
operations = [
migrations.CreateModel(
name='PuContract',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('name', models.CharField(max_length=100, verbose_name='合同名称')),
('number', models.CharField(max_length=100, unique=True, verbose_name='合同编号')),
('contract_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='合同金额')),
('sign_date', models.DateField(verbose_name='签订日期')),
('effective_date', models.DateField(blank=True, null=True, verbose_name='生效日期')),
('end_date', models.DateField(blank=True, null=True, verbose_name='截止日期')),
('status', models.PositiveSmallIntegerField(choices=[(10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止')], default=10, help_text="((10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止'))", verbose_name='合同状态')),
('settlement_status', models.PositiveSmallIntegerField(choices=[(10, '未付款'), (20, '部分付款'), (30, '全部付款')], default=10, help_text="((10, '未付款'), (20, '部分付款'), (30, '全部付款'))", verbose_name='结算状态')),
('paid_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计已付款')),
('unpaid_amount', models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计未付款')),
('pay_progress', models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='付款进度')),
('description', models.CharField(blank=True, max_length=200, null=True, verbose_name='描述')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
],
options={
'verbose_name': '采购合同',
'verbose_name_plural': '采购合同',
},
),
migrations.AlterField(
model_name='puorder',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='puorder',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='puorder',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='puplan',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='puplan',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='puplan',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='puplanitem',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='puplanitem',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='puplanitem',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='quotationapply',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='quotationapply',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='supplier',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='supplier',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='supplier',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='supplieraudit',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='supplieraudit',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.CreateModel(
name='PuContractRecord',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('record_date', models.DateField(verbose_name='付款日期')),
('amount', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='付款金额')),
('stage_type', models.PositiveSmallIntegerField(choices=[(10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他')], default=40, help_text="((10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他'))", verbose_name='阶段类型')),
('pay_method', models.PositiveSmallIntegerField(choices=[(10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他')], default=10, help_text="((10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他'))", verbose_name='付款方式')),
('voucher_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='凭证号')),
('remark', models.CharField(blank=True, max_length=200, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='pum.pucontract', verbose_name='采购合同')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '采购合同付款流水',
'verbose_name_plural': '采购合同付款流水',
'ordering': ['-record_date', '-create_time'],
},
),
migrations.AddField(
model_name='pucontract',
name='supplier',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='pum.supplier', verbose_name='供应商'),
),
migrations.AddField(
model_name='pucontract',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AddField(
model_name='puorder',
name='contract',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='pum.pucontract', verbose_name='采购合同'),
),
]

View File

@ -1,4 +1,7 @@
from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel, CommonADModel from apps.utils.models import CommonBModel, BaseModel, CommonBDModel, CommonADModel
from apps.mtm.models import Material from apps.mtm.models import Material
from apps.wf.models import Ticket from apps.wf.models import Ticket
@ -71,6 +74,9 @@ class PuOrder(CommonBModel):
number = models.CharField('订单编号', max_length=20, null=True, blank=True) number = models.CharField('订单编号', max_length=20, null=True, blank=True)
supplier = models.ForeignKey( supplier = models.ForeignKey(
Supplier, verbose_name='供应商', on_delete=models.CASCADE) Supplier, verbose_name='供应商', on_delete=models.CASCADE)
contract = models.ForeignKey(
'pum.PuContract', verbose_name='采购合同', on_delete=models.SET_NULL,
null=True, blank=True, related_name='orders')
delivery_date = models.DateField('截止到货日期', null=True, blank=True) delivery_date = models.DateField('截止到货日期', null=True, blank=True)
submit_time = models.DateTimeField('提交时间', null=True, blank=True) submit_time = models.DateTimeField('提交时间', null=True, blank=True)
submit_user = models.ForeignKey( submit_user = models.ForeignKey(
@ -126,3 +132,151 @@ class QuotationApply(CommonADModel):
apply_date = models.DateField(verbose_name="申请日期",auto_now_add=True) apply_date = models.DateField(verbose_name="申请日期",auto_now_add=True)
ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单', ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单',
on_delete=models.CASCADE, related_name='quo_ticket', null=True, blank=True) on_delete=models.CASCADE, related_name='quo_ticket', null=True, blank=True)
class PuContract(CommonBDModel):
"""
TN:采购合同
"""
STATUS_DRAFT = 10
STATUS_ACTIVE = 20
STATUS_DONE = 30
STATUS_TERMINATED = 40
STATUS_CHOICES = (
(STATUS_DRAFT, '草稿'),
(STATUS_ACTIVE, '执行中'),
(STATUS_DONE, '已完成'),
(STATUS_TERMINATED, '已终止'),
)
SETTLEMENT_UNPAID = 10
SETTLEMENT_PARTIAL = 20
SETTLEMENT_FULL = 30
SETTLEMENT_CHOICES = (
(SETTLEMENT_UNPAID, '未付款'),
(SETTLEMENT_PARTIAL, '部分付款'),
(SETTLEMENT_FULL, '全部付款'),
)
name = models.CharField('合同名称', max_length=100)
number = models.CharField('合同编号', max_length=100, unique=True)
supplier = models.ForeignKey(Supplier, verbose_name='供应商', on_delete=models.CASCADE, related_name='contracts')
contract_amount = models.DecimalField('合同金额', max_digits=14, decimal_places=2, default=0)
sign_date = models.DateField('签订日期')
effective_date = models.DateField('生效日期', null=True, blank=True)
end_date = models.DateField('截止日期', null=True, blank=True)
status = models.PositiveSmallIntegerField(
'合同状态', choices=STATUS_CHOICES, default=STATUS_DRAFT, help_text=str(STATUS_CHOICES))
settlement_status = models.PositiveSmallIntegerField(
'结算状态', choices=SETTLEMENT_CHOICES, default=SETTLEMENT_UNPAID, help_text=str(SETTLEMENT_CHOICES))
paid_amount = models.DecimalField('累计已付款', max_digits=14, decimal_places=2, default=0)
unpaid_amount = models.DecimalField('累计未付款', max_digits=14, decimal_places=2, default=0)
pay_progress = models.DecimalField('付款进度', max_digits=5, decimal_places=2, default=0)
description = models.CharField('描述', max_length=200, blank=True, null=True)
class Meta:
verbose_name = '采购合同'
verbose_name_plural = verbose_name
def __str__(self):
return self.name
def save(self, *args, **kwargs):
refresh_settlement = kwargs.pop('refresh_settlement', True)
super().save(*args, **kwargs)
if refresh_settlement:
self.refresh_settlement()
def refresh_settlement(self):
paid_amount = PuContractRecord.objects.filter(contract=self).aggregate(
total=Sum('amount')
)['total'] or Decimal('0.00')
contract_amount = Decimal(str(self.contract_amount or 0)).quantize(Decimal('0.01'))
unpaid_amount = contract_amount - paid_amount
if unpaid_amount < Decimal('0.00'):
unpaid_amount = Decimal('0.00')
if contract_amount <= Decimal('0.00'):
pay_progress = Decimal('0.00')
else:
pay_progress = (paid_amount * Decimal('100.00') / contract_amount).quantize(Decimal('0.01'))
if pay_progress > Decimal('100.00'):
pay_progress = Decimal('100.00')
if paid_amount <= Decimal('0.00'):
settlement_status = self.SETTLEMENT_UNPAID
elif paid_amount >= contract_amount and contract_amount > Decimal('0.00'):
settlement_status = self.SETTLEMENT_FULL
else:
settlement_status = self.SETTLEMENT_PARTIAL
status = self.status
if status != self.STATUS_TERMINATED:
if paid_amount <= Decimal('0.00'):
status = self.STATUS_DRAFT
elif paid_amount >= contract_amount and contract_amount > Decimal('0.00'):
status = self.STATUS_DONE
else:
status = self.STATUS_ACTIVE
type(self).objects.filter(pk=self.pk).update(
paid_amount=paid_amount,
unpaid_amount=unpaid_amount,
pay_progress=pay_progress,
settlement_status=settlement_status,
status=status,
)
self.paid_amount = paid_amount
self.unpaid_amount = unpaid_amount
self.pay_progress = pay_progress
self.settlement_status = settlement_status
self.status = status
class PuContractRecord(CommonBDModel):
"""
TN:采购合同付款流水
"""
STAGE_FIRST = 10
STAGE_MIDDLE = 20
STAGE_FINAL = 30
STAGE_OTHER = 40
STAGE_CHOICES = (
(STAGE_FIRST, '首款'),
(STAGE_MIDDLE, '中间款'),
(STAGE_FINAL, '尾款'),
(STAGE_OTHER, '其他'),
)
PAY_BANK = 10
PAY_CASH = 20
PAY_ACCEPTANCE = 30
PAY_WECHAT = 40
PAY_ALIPAY = 50
PAY_OTHER = 60
PAY_METHOD_CHOICES = (
(PAY_BANK, '银行转账'),
(PAY_CASH, '现金'),
(PAY_ACCEPTANCE, '承兑'),
(PAY_WECHAT, '微信'),
(PAY_ALIPAY, '支付宝'),
(PAY_OTHER, '其他'),
)
contract = models.ForeignKey(
PuContract, verbose_name='采购合同', on_delete=models.CASCADE, related_name='records')
record_date = models.DateField('付款日期')
amount = models.DecimalField('付款金额', max_digits=14, decimal_places=2)
stage_type = models.PositiveSmallIntegerField(
'阶段类型', choices=STAGE_CHOICES, default=STAGE_OTHER, help_text=str(STAGE_CHOICES))
pay_method = models.PositiveSmallIntegerField(
'付款方式', choices=PAY_METHOD_CHOICES, default=PAY_BANK, help_text=str(PAY_METHOD_CHOICES))
voucher_no = models.CharField('凭证号', max_length=100, null=True, blank=True)
remark = models.CharField('备注', max_length=200, null=True, blank=True)
class Meta:
verbose_name = '采购合同付款流水'
verbose_name_plural = verbose_name
ordering = ['-record_date', '-create_time']
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.contract.refresh_settlement()
def delete(self, using=None, *args, **kwargs):
contract = self.contract
result = super().delete(using=using, *args, **kwargs)
contract.refresh_settlement()
return result

View File

@ -1,9 +1,11 @@
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer from apps.utils.serializers import CustomModelSerializer
from apps.utils.constants import EXCLUDE_FIELDS_DEPT, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS from apps.utils.constants import EXCLUDE_FIELDS_DEPT, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS
from rest_framework.exceptions import ValidationError, ParseError from rest_framework.exceptions import ValidationError, ParseError
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.mtm.serializers import MaterialSerializer, MaterialSimpleSerializer from apps.mtm.serializers import MaterialSerializer, MaterialSimpleSerializer
from django.db import transaction from django.db import transaction
from .services import PumService from .services import PumService
@ -99,6 +101,14 @@ class PuOrderSerializer(CustomModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = EXCLUDE_FIELDS_DEPT + ['state', 'submit_time', 'total_price'] read_only_fields = EXCLUDE_FIELDS_DEPT + ['state', 'submit_time', 'total_price']
def validate(self, attrs):
contract = attrs.get('contract', None)
if contract:
attrs['supplier'] = contract.supplier
if attrs.get('supplier', None) is None:
raise ValidationError('未选择供应商')
return attrs
def update(self, instance, validated_data): def update(self, instance, validated_data):
validated_data.pop('supplier') validated_data.pop('supplier')
if instance.state != PuOrder.PUORDER_CREATE: if instance.state != PuOrder.PUORDER_CREATE:
@ -165,3 +175,39 @@ class QuotationApplySerializer(CustomModelSerializer):
model = QuotationApply model = QuotationApply
fields = "__all__" fields = "__all__"
read_only_fields = EXCLUDE_FIELDS read_only_fields = EXCLUDE_FIELDS
class PuContractSerializer(CustomModelSerializer):
supplier_name = serializers.CharField(source='supplier.name', read_only=True)
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
update_by_name = serializers.CharField(source='update_by.name', read_only=True)
class Meta:
model = PuContract
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'paid_amount', 'unpaid_amount', 'pay_progress', 'settlement_status']
class PuContractRecordSerializer(CustomModelSerializer):
contract_number = serializers.CharField(source='contract.number', read_only=True)
supplier_name = serializers.CharField(source='contract.supplier.name', read_only=True)
class Meta:
model = PuContractRecord
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept']
def validate(self, attrs):
contract = attrs.get('contract', getattr(self.instance, 'contract', None))
amount = attrs.get('amount', getattr(self.instance, 'amount', None))
if contract is None or amount is None:
return attrs
if contract.status == PuContract.STATUS_TERMINATED:
raise ValidationError('合同已终止,不可操作付款流水')
qs = PuContractRecord.objects.filter(contract=contract)
if self.instance is not None:
qs = qs.exclude(id=self.instance.id)
total = sum((item.amount for item in qs), Decimal('0.00')) + amount
if total > contract.contract_amount:
raise ValidationError('累计付款金额不可超过合同金额')
return attrs

View File

@ -1,3 +1,178 @@
from decimal import Decimal
from django.test import TestCase from django.test import TestCase
# Create your tests here. from apps.pum.models import Supplier
from apps.pum.serializers import PuOrderSerializer
from apps.pum.serializers import PuContractRecordSerializer
from rest_framework.exceptions import ParseError
class PuContractSettlementTests(TestCase):
def test_purchase_contract_record_updates_paid_summary(self):
supplier = Supplier.objects.create(name='供应商A')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同A',
number='PC-001',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.paid_amount, Decimal('800.00'))
self.assertEqual(contract.unpaid_amount, Decimal('1200.00'))
self.assertEqual(contract.pay_progress, Decimal('40.00'))
self.assertEqual(contract.status, contract.STATUS_ACTIVE)
def test_purchase_contract_record_delete_refreshes_summary_and_is_physical(self):
supplier = Supplier.objects.create(name='供应商C')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同C',
number='PC-003',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
record.delete()
contract.refresh_from_db()
self.assertEqual(contract.paid_amount, Decimal('0.00'))
self.assertEqual(contract.unpaid_amount, Decimal('2000.00'))
self.assertEqual(contract.pay_progress, Decimal('0.00'))
self.assertEqual(contract.status, contract.STATUS_DRAFT)
self.assertFalse(PuContractRecord._base_manager.filter(pk=record.pk).exists())
def test_purchase_contract_delete_is_physical(self):
supplier = Supplier.objects.create(name='供应商D')
from apps.pum.models import PuContract
contract = PuContract.objects.create(
name='采购合同D',
number='PC-004',
contract_amount=Decimal('500.00'),
supplier=supplier,
sign_date='2026-04-20',
)
contract.delete()
self.assertFalse(PuContract._base_manager.filter(pk=contract.pk).exists())
def test_purchase_contract_status_auto_transitions_by_records(self):
supplier = Supplier.objects.create(name='供应商E')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同E',
number='PC-005',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
self.assertEqual(contract.status, PuContract.STATUS_DRAFT)
first_record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_ACTIVE)
PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('1200.00'),
stage_type=PuContractRecord.STAGE_FINAL,
)
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_DONE)
first_record.delete()
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_ACTIVE)
def test_purchase_terminated_contract_forbids_record_changes(self):
supplier = Supplier.objects.create(name='供应商F')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同F',
number='PC-006',
contract_amount=Decimal('1000.00'),
supplier=supplier,
sign_date='2026-04-20',
status=PuContract.STATUS_TERMINATED,
)
serializer = PuContractRecordSerializer(data={
'contract': contract.id,
'record_date': '2026-04-21',
'amount': '100.00',
'stage_type': 10,
'pay_method': 10,
})
self.assertFalse(serializer.is_valid())
self.assertIn('合同已终止,不可操作付款流水', str(serializer.errors))
contract.status = PuContract.STATUS_DRAFT
contract.save(refresh_settlement=False)
record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-20',
amount=Decimal('100.00'),
stage_type=PuContractRecord.STAGE_OTHER,
)
contract.status = PuContract.STATUS_TERMINATED
contract.save(refresh_settlement=False)
with self.assertRaises(ParseError):
from apps.pum.views import PuContractRecordViewSet
viewset = PuContractRecordViewSet()
viewset.request = None
viewset.perform_destroy(record)
def test_purchase_order_serializer_accepts_purchase_contract(self):
supplier = Supplier.objects.create(name='供应商B')
from apps.pum.models import PuContract
contract = PuContract.objects.create(
name='采购合同B',
number='PC-002',
contract_amount=Decimal('500.00'),
supplier=supplier,
sign_date='2026-04-20',
)
serializer = PuOrderSerializer(data={'supplier': supplier.id, 'contract': contract.id})
self.assertTrue(serializer.is_valid(), serializer.errors)

View File

@ -1,6 +1,6 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet) from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet, PuContractViewSet, PuContractRecordViewSet)
# from apps.pum.views import SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet # from apps.pum.views import SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet
API_BASE_URL = 'api/pum/' API_BASE_URL = 'api/pum/'
@ -11,6 +11,8 @@ router.register('supplier', SupplierViewSet, basename='supplier')
router.register('supplieraudit', SupplierAuditViewSet, basename='supplieraudit') router.register('supplieraudit', SupplierAuditViewSet, basename='supplieraudit')
router.register('pu_plan', PuPlanViewSet, basename='pu_plan') router.register('pu_plan', PuPlanViewSet, basename='pu_plan')
router.register('pu_planitem', PuPlanItemViewSet, basename='pu_planitem') router.register('pu_planitem', PuPlanItemViewSet, basename='pu_planitem')
router.register('pu_contract', PuContractViewSet, basename='pu_contract')
router.register('pu_contract_record', PuContractRecordViewSet, basename='pu_contract_record')
router.register('pu_order', PuOrderViewSet, basename='pu_order') router.register('pu_order', PuOrderViewSet, basename='pu_order')
router.register('pu_orderitem', PuOrderItemViewSet, basename='pu_orderitem') router.register('pu_orderitem', PuOrderItemViewSet, basename='pu_orderitem')
router.register('quotation', QuotationApplyViewSet, basename='quotation') router.register('quotation', QuotationApplyViewSet, basename='quotation')

View File

@ -1,8 +1,9 @@
from django.shortcuts import render from django.shortcuts import render
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet
from apps.pum.serializers import (SupplierSerializer, PuPlanSerializer, PuPlanItemSerializer, QuotationApplySerializer, from apps.pum.serializers import (SupplierSerializer, PuPlanSerializer, PuPlanItemSerializer, QuotationApplySerializer,
PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer) PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer,
PuContractSerializer, PuContractRecordSerializer)
from rest_framework.exceptions import ParseError, PermissionDenied from rest_framework.exceptions import ParseError, PermissionDenied
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework import serializers from rest_framework import serializers
@ -223,3 +224,50 @@ class QuotationApplyViewSet(TicketMixin, CustomModelViewSet):
search_fields = ['product_name', 'customer_name','contact_person'] search_fields = ['product_name', 'customer_name','contact_person']
ordering = ['create_time'] ordering = ['create_time']
workflow_key = "wf_quotation" workflow_key = "wf_quotation"
class PuContractViewSet(CustomModelViewSet):
"""
list: 采购合同
采购合同
"""
queryset = PuContract.objects.all()
serializer_class = PuContractSerializer
search_fields = ['name', 'number', 'supplier__name']
select_related_fields = ['supplier', 'create_by', 'update_by']
filterset_fields = ['supplier', 'status', 'settlement_status']
def perform_destroy(self, instance):
if PuOrder.objects.filter(contract=instance).exists():
raise ParseError('该采购合同存在采购订单不可删除')
instance.delete()
class PuContractRecordViewSet(CustomModelViewSet):
"""
list: 采购合同付款流水
采购合同付款流水
"""
perms_map = {
'get': '*',
'post': 'pu_contract.update',
'put': 'pu_contract.update',
'patch': 'pu_contract.update',
'delete': 'pu_contract.update',
}
queryset = PuContractRecord.objects.all()
serializer_class = PuContractRecordSerializer
search_fields = ['contract__number', 'contract__name', 'voucher_no', 'remark']
select_related_fields = ['contract', 'contract__supplier', 'create_by', 'update_by']
filterset_fields = {
'contract': ['exact'],
'stage_type': ['exact', 'in'],
'pay_method': ['exact', 'in'],
}
def perform_destroy(self, instance):
if instance.contract.status == PuContract.STATUS_TERMINATED:
raise ParseError('合同已终止,不可删除付款流水')
instance.delete()

View File

@ -0,0 +1,112 @@
# Generated by Django 4.2.27 on 2026-03-27 00:48
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
('qm', '0055_alter_ftestitem_ftest'),
]
operations = [
migrations.AddField(
model_name='ptest',
name='conclusion',
field=models.TextField(blank=True, null=True, verbose_name='结论'),
),
migrations.AddField(
model_name='ptest',
name='specification_zwnd',
field=models.TextField(blank=True, null=True, verbose_name='中温粘度规格'),
),
migrations.AlterField(
model_name='defect',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='defect',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='ftest',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='ftest',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='ftest',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='ftestwork',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='ftestwork',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='ftestwork',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='ptest',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='ptest',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='qct',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='qct',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='quastat',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='quastat',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='quastat',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='testitem',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='testitem',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
]

View File

@ -449,3 +449,5 @@ class Ptest(CommonAModel):
val_pzxs = models.FloatField( val_pzxs = models.FloatField(
'膨胀系数', help_text='30-300℃', null=True, blank=True) '膨胀系数', help_text='30-300℃', null=True, blank=True)
val_zgwd = models.FloatField('升至最高温度', null=True, blank=True) val_zgwd = models.FloatField('升至最高温度', null=True, blank=True)
specification_zwnd = models.TextField('中温粘度规格', null=True, blank=True)
conclusion = models.TextField('结论', null=True, blank=True)

View File

@ -148,7 +148,7 @@ class RemployeeCreateSerializer(CustomModelSerializer):
if Remployee.objects.filter(id_number=validated_data['id_number'], rparty=validated_data['rparty']).exists(): if Remployee.objects.filter(id_number=validated_data['id_number'], rparty=validated_data['rparty']).exists():
raise ValidationError('该成员已存在') raise ValidationError('该成员已存在')
with transaction.atomic(): with transaction.atomic():
if settings.DAHUA_ENABLED: if getattr(settings, 'DAHUA_ENABLED', False):
dhClient.request(**dhapis['person_img_upload'], file_path_rela=validated_data['photo']) dhClient.request(**dhapis['person_img_upload'], file_path_rela=validated_data['photo'])
return super().create(validated_data) return super().create(validated_data)
@ -161,7 +161,7 @@ class RemployeeUpdateSerializer(CustomModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
with transaction.atomic(): with transaction.atomic():
if settings.DAHUA_ENABLED: if getattr(settings, 'DAHUA_ENABLED', False):
dhClient.request(**dhapis['person_img_upload'], file_path_rela=validated_data['photo']) dhClient.request(**dhapis['person_img_upload'], file_path_rela=validated_data['photo'])
return super().update(instance, validated_data) return super().update(instance, validated_data)

View File

@ -0,0 +1,122 @@
# Generated by Django 4.2.27 on 2026-04-20 06:02
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('sam', '0008_alter_orderitem_order'),
]
operations = [
migrations.AddField(
model_name='contract',
name='effective_date',
field=models.DateField(blank=True, null=True, verbose_name='生效日期'),
),
migrations.AddField(
model_name='contract',
name='end_date',
field=models.DateField(blank=True, null=True, verbose_name='截止日期'),
),
migrations.AddField(
model_name='contract',
name='receive_progress',
field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='到款进度'),
),
migrations.AddField(
model_name='contract',
name='received_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计已到款'),
),
migrations.AddField(
model_name='contract',
name='settlement_status',
field=models.PositiveSmallIntegerField(choices=[(10, '未到款'), (20, '部分到款'), (30, '全部到款')], default=10, help_text="((10, '未到款'), (20, '部分到款'), (30, '全部到款'))", verbose_name='结算状态'),
),
migrations.AddField(
model_name='contract',
name='status',
field=models.PositiveSmallIntegerField(choices=[(10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止')], default=10, help_text="((10, '草稿'), (20, '执行中'), (30, '已完成'), (40, '已终止'))", verbose_name='合同状态'),
),
migrations.AddField(
model_name='contract',
name='unreceived_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=14, verbose_name='累计未到款'),
),
migrations.AlterField(
model_name='contract',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='contract',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='contract',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='customer',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='customer',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='customer',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='order',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='order',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='order',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.CreateModel(
name='ContractRecord',
fields=[
('id', models.CharField(editable=False, help_text='主键ID', max_length=20, primary_key=True, serialize=False, verbose_name='主键ID')),
('create_time', models.DateTimeField(default=django.utils.timezone.now, help_text='创建时间', verbose_name='创建时间')),
('update_time', models.DateTimeField(auto_now=True, help_text='修改时间', verbose_name='修改时间')),
('is_deleted', models.BooleanField(default=False, help_text='删除标记', verbose_name='删除标记')),
('record_date', models.DateField(verbose_name='到款日期')),
('amount', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='到款金额')),
('stage_type', models.PositiveSmallIntegerField(choices=[(10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他')], default=40, help_text="((10, '首款'), (20, '中间款'), (30, '尾款'), (40, '其他'))", verbose_name='阶段类型')),
('pay_method', models.PositiveSmallIntegerField(choices=[(10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他')], default=10, help_text="((10, '银行转账'), (20, '现金'), (30, '承兑'), (40, '微信'), (50, '支付宝'), (60, '其他'))", verbose_name='收款方式')),
('voucher_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='凭证号')),
('remark', models.CharField(blank=True, max_length=200, null=True, verbose_name='备注')),
('belong_dept', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门')),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='sam.contract', verbose_name='销售合同')),
('create_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('update_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人')),
],
options={
'verbose_name': '销售合同到款流水',
'verbose_name_plural': '销售合同到款流水',
'ordering': ['-record_date', '-create_time'],
},
),
]

View File

@ -1,4 +1,7 @@
from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel from apps.utils.models import CommonBModel, BaseModel, CommonBDModel
from apps.mtm.models import Material from apps.mtm.models import Material
@ -24,16 +27,43 @@ class Customer(CommonBModel):
return self.name return self.name
class Contract(CommonBModel): class Contract(CommonBDModel):
""" """
TN:合同信息 TN:合同信息
""" """
STATUS_DRAFT = 10
STATUS_ACTIVE = 20
STATUS_DONE = 30
STATUS_TERMINATED = 40
STATUS_CHOICES = (
(STATUS_DRAFT, '草稿'),
(STATUS_ACTIVE, '执行中'),
(STATUS_DONE, '已完成'),
(STATUS_TERMINATED, '已终止'),
)
SETTLEMENT_UNRECEIVED = 10
SETTLEMENT_PARTIAL = 20
SETTLEMENT_FULL = 30
SETTLEMENT_CHOICES = (
(SETTLEMENT_UNRECEIVED, '未到款'),
(SETTLEMENT_PARTIAL, '部分到款'),
(SETTLEMENT_FULL, '全部到款'),
)
name = models.CharField('合同名称', max_length=100) name = models.CharField('合同名称', max_length=100)
number = models.CharField('合同编号', max_length=100, unique=True) number = models.CharField('合同编号', max_length=100, unique=True)
amount = models.IntegerField('合同金额', default=0) amount = models.IntegerField('合同金额', default=0)
customer = models.ForeignKey(Customer, verbose_name='关联客户', customer = models.ForeignKey(Customer, verbose_name='关联客户',
on_delete=models.CASCADE, related_name='contract_customer') on_delete=models.CASCADE, related_name='contract_customer')
sign_date = models.DateField('签订日期') sign_date = models.DateField('签订日期')
effective_date = models.DateField('生效日期', null=True, blank=True)
end_date = models.DateField('截止日期', null=True, blank=True)
status = models.PositiveSmallIntegerField(
'合同状态', choices=STATUS_CHOICES, default=STATUS_DRAFT, help_text=str(STATUS_CHOICES))
settlement_status = models.PositiveSmallIntegerField(
'结算状态', choices=SETTLEMENT_CHOICES, default=SETTLEMENT_UNRECEIVED, help_text=str(SETTLEMENT_CHOICES))
received_amount = models.DecimalField('累计已到款', max_digits=14, decimal_places=2, default=0)
unreceived_amount = models.DecimalField('累计未到款', max_digits=14, decimal_places=2, default=0)
receive_progress = models.DecimalField('到款进度', max_digits=5, decimal_places=2, default=0)
description = models.CharField('描述', max_length=200, blank=True, null=True) description = models.CharField('描述', max_length=200, blank=True, null=True)
class Meta: class Meta:
@ -43,6 +73,53 @@ class Contract(CommonBModel):
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
refresh_settlement = kwargs.pop('refresh_settlement', True)
super().save(*args, **kwargs)
if refresh_settlement:
self.refresh_settlement()
def refresh_settlement(self):
received_amount = ContractRecord.objects.filter(contract=self).aggregate(
total=Sum('amount')
)['total'] or Decimal('0.00')
contract_amount = Decimal(str(self.amount or 0)).quantize(Decimal('0.01'))
unreceived_amount = contract_amount - received_amount
if unreceived_amount < Decimal('0.00'):
unreceived_amount = Decimal('0.00')
if contract_amount <= Decimal('0.00'):
receive_progress = Decimal('0.00')
else:
receive_progress = (received_amount * Decimal('100.00') / contract_amount).quantize(Decimal('0.01'))
if receive_progress > Decimal('100.00'):
receive_progress = Decimal('100.00')
if received_amount <= Decimal('0.00'):
settlement_status = self.SETTLEMENT_UNRECEIVED
elif received_amount >= contract_amount and contract_amount > Decimal('0.00'):
settlement_status = self.SETTLEMENT_FULL
else:
settlement_status = self.SETTLEMENT_PARTIAL
status = self.status
if status != self.STATUS_TERMINATED:
if received_amount <= Decimal('0.00'):
status = self.STATUS_DRAFT
elif received_amount >= contract_amount and contract_amount > Decimal('0.00'):
status = self.STATUS_DONE
else:
status = self.STATUS_ACTIVE
type(self).objects.filter(pk=self.pk).update(
received_amount=received_amount,
unreceived_amount=unreceived_amount,
receive_progress=receive_progress,
settlement_status=settlement_status,
status=status,
)
self.received_amount = received_amount
self.unreceived_amount = unreceived_amount
self.receive_progress = receive_progress
self.settlement_status = settlement_status
self.status = status
class Order(CommonBModel): class Order(CommonBModel):
""" """
@ -87,3 +164,58 @@ class OrderItem(BaseModel):
delivered_count = models.PositiveIntegerField('已交货数量', default=0) delivered_count = models.PositiveIntegerField('已交货数量', default=0)
utask = models.ForeignKey('pm.utask', verbose_name='关联生产大任务', utask = models.ForeignKey('pm.utask', verbose_name='关联生产大任务',
on_delete=models.SET_NULL, null=True, blank=True) on_delete=models.SET_NULL, null=True, blank=True)
class ContractRecord(CommonBDModel):
"""
TN:销售合同到款流水
"""
STAGE_FIRST = 10
STAGE_MIDDLE = 20
STAGE_FINAL = 30
STAGE_OTHER = 40
STAGE_CHOICES = (
(STAGE_FIRST, '首款'),
(STAGE_MIDDLE, '中间款'),
(STAGE_FINAL, '尾款'),
(STAGE_OTHER, '其他'),
)
PAY_BANK = 10
PAY_CASH = 20
PAY_ACCEPTANCE = 30
PAY_WECHAT = 40
PAY_ALIPAY = 50
PAY_OTHER = 60
PAY_METHOD_CHOICES = (
(PAY_BANK, '银行转账'),
(PAY_CASH, '现金'),
(PAY_ACCEPTANCE, '承兑'),
(PAY_WECHAT, '微信'),
(PAY_ALIPAY, '支付宝'),
(PAY_OTHER, '其他'),
)
contract = models.ForeignKey(
Contract, verbose_name='销售合同', on_delete=models.CASCADE, related_name='records')
record_date = models.DateField('到款日期')
amount = models.DecimalField('到款金额', max_digits=14, decimal_places=2)
stage_type = models.PositiveSmallIntegerField(
'阶段类型', choices=STAGE_CHOICES, default=STAGE_OTHER, help_text=str(STAGE_CHOICES))
pay_method = models.PositiveSmallIntegerField(
'收款方式', choices=PAY_METHOD_CHOICES, default=PAY_BANK, help_text=str(PAY_METHOD_CHOICES))
voucher_no = models.CharField('凭证号', max_length=100, null=True, blank=True)
remark = models.CharField('备注', max_length=200, null=True, blank=True)
class Meta:
verbose_name = '销售合同到款流水'
verbose_name_plural = verbose_name
ordering = ['-record_date', '-create_time']
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.contract.refresh_settlement()
def delete(self, using=None, *args, **kwargs):
contract = self.contract
result = super().delete(using=using, *args, **kwargs)
contract.refresh_settlement()
return result

View File

@ -1,6 +1,8 @@
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer from apps.utils.serializers import CustomModelSerializer
from apps.sam.models import Customer, Contract, Order, OrderItem from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from apps.mtm.serializers import MaterialSerializer from apps.mtm.serializers import MaterialSerializer
@ -81,3 +83,29 @@ class OrderItemSerializer(CustomModelSerializer):
validated_data.pop('product', None) validated_data.pop('product', None)
validated_data.pop('order', None) validated_data.pop('order', None)
return super().update(instance, validated_data) return super().update(instance, validated_data)
class ContractRecordSerializer(CustomModelSerializer):
contract_number = serializers.CharField(source='contract.number', read_only=True)
customer_name = serializers.CharField(source='contract.customer.name', read_only=True)
class Meta:
model = ContractRecord
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept']
def validate(self, attrs):
contract = attrs.get('contract', getattr(self.instance, 'contract', None))
amount = attrs.get('amount', getattr(self.instance, 'amount', None))
if contract is None or amount is None:
return attrs
if contract.status == Contract.STATUS_TERMINATED:
raise ValidationError('合同已终止,不可操作到款流水')
qs = ContractRecord.objects.filter(contract=contract)
if self.instance is not None:
qs = qs.exclude(id=self.instance.id)
total = sum((item.amount for item in qs), Decimal('0.00')) + amount
contract_amount = Decimal(str(contract.amount or 0))
if total > contract_amount:
raise ValidationError('累计到款金额不可超过合同金额')
return attrs

View File

@ -1,3 +1,178 @@
from decimal import Decimal
from django.test import TestCase from django.test import TestCase
# Create your tests here. from apps.sam.models import Contract, Customer
from apps.sam.serializers import ContractRecordSerializer
from rest_framework.exceptions import ParseError
class ContractSettlementTests(TestCase):
def test_sales_contract_record_updates_received_summary(self):
customer = Customer.objects.create(
name='客户A',
contact='张三',
contact_phone='13800000001',
)
contract = Contract.objects.create(
name='销售合同A',
number='SC-001',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('200.00'),
stage_type=ContractRecord.STAGE_MIDDLE,
)
contract.refresh_from_db()
self.assertEqual(contract.received_amount, Decimal('500.00'))
self.assertEqual(contract.unreceived_amount, Decimal('500.00'))
self.assertEqual(contract.receive_progress, Decimal('50.00'))
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
def test_sales_contract_record_delete_refreshes_summary_and_is_physical(self):
customer = Customer.objects.create(
name='客户B',
contact='李四',
contact_phone='13800000002',
)
contract = Contract.objects.create(
name='销售合同B',
number='SC-002',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
record.delete()
contract.refresh_from_db()
self.assertEqual(contract.received_amount, Decimal('0.00'))
self.assertEqual(contract.unreceived_amount, Decimal('1000.00'))
self.assertEqual(contract.receive_progress, Decimal('0.00'))
self.assertEqual(contract.status, Contract.STATUS_DRAFT)
self.assertFalse(ContractRecord._base_manager.filter(pk=record.pk).exists())
def test_sales_contract_delete_is_physical(self):
customer = Customer.objects.create(
name='客户C',
contact='王五',
contact_phone='13800000003',
)
contract = Contract.objects.create(
name='销售合同C',
number='SC-003',
amount=500,
customer=customer,
sign_date='2026-04-20',
)
contract.delete()
self.assertFalse(Contract._base_manager.filter(pk=contract.pk).exists())
def test_sales_contract_status_auto_transitions_by_records(self):
customer = Customer.objects.create(
name='客户D',
contact='赵六',
contact_phone='13800000004',
)
contract = Contract.objects.create(
name='销售合同D',
number='SC-004',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
self.assertEqual(contract.status, Contract.STATUS_DRAFT)
first_record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('700.00'),
stage_type=ContractRecord.STAGE_FINAL,
)
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_DONE)
first_record.delete()
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
def test_sales_terminated_contract_forbids_record_changes(self):
customer = Customer.objects.create(
name='客户E',
contact='孙七',
contact_phone='13800000005',
)
contract = Contract.objects.create(
name='销售合同E',
number='SC-005',
amount=1000,
customer=customer,
sign_date='2026-04-20',
status=Contract.STATUS_TERMINATED,
)
serializer = ContractRecordSerializer(data={
'contract': contract.id,
'record_date': '2026-04-21',
'amount': '100.00',
'stage_type': 10,
'pay_method': 10,
})
self.assertFalse(serializer.is_valid())
self.assertIn('合同已终止,不可操作到款流水', str(serializer.errors))
contract.status = Contract.STATUS_DRAFT
contract.save(refresh_settlement=False)
from apps.sam.models import ContractRecord
record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-20',
amount=Decimal('100.00'),
stage_type=ContractRecord.STAGE_OTHER,
)
contract.status = Contract.STATUS_TERMINATED
contract.save(refresh_settlement=False)
with self.assertRaises(ParseError):
from apps.sam.views import ContractRecordViewSet
viewset = ContractRecordViewSet()
viewset.request = None
viewset.perform_destroy(record)

View File

@ -1,6 +1,6 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet) from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet, ContractRecordViewSet)
API_BASE_URL = 'api/sam/' API_BASE_URL = 'api/sam/'
HTML_BASE_URL = 'dhtml/sam/' HTML_BASE_URL = 'dhtml/sam/'
@ -8,6 +8,7 @@ HTML_BASE_URL = 'dhtml/sam/'
router = DefaultRouter() router = DefaultRouter()
router.register('customer', CustomerViewSet, basename='customer') router.register('customer', CustomerViewSet, basename='customer')
router.register('contract', ContractViewSet, basename='contract') router.register('contract', ContractViewSet, basename='contract')
router.register('contract_record', ContractRecordViewSet, basename='contract_record')
router.register('order', OrderViewSet, basename='order') router.register('order', OrderViewSet, basename='order')
router.register('orderitem', OrderItemViewSet, basename='orderitem') router.register('orderitem', OrderItemViewSet, basename='orderitem')
urlpatterns = [ urlpatterns = [

View File

@ -1,7 +1,7 @@
from django.shortcuts import render from django.shortcuts import render
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
from apps.sam.models import Customer, Contract, Order, OrderItem from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer, ContractRecordSerializer
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin, CreateModelMixin, DestroyModelMixin from rest_framework.mixins import ListModelMixin, CreateModelMixin, DestroyModelMixin
from apps.utils.mixins import BulkCreateModelMixin from apps.utils.mixins import BulkCreateModelMixin
@ -46,6 +46,7 @@ class ContractViewSet(CustomModelViewSet):
def perform_destroy(self, instance): def perform_destroy(self, instance):
if Order.objects.filter(contract=instance).exists(): if Order.objects.filter(contract=instance).exists():
raise ParseError('该合同存在订单不可删除') raise ParseError('该合同存在订单不可删除')
instance.delete()
class OrderViewSet(CustomModelViewSet): class OrderViewSet(CustomModelViewSet):
@ -106,3 +107,32 @@ class OrderItemViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, Cust
if instance.order.state != Order.ORDER_CREATE: if instance.order.state != Order.ORDER_CREATE:
raise ParseError('该订单状态下不可删除') raise ParseError('该订单状态下不可删除')
return super().perform_destroy(instance) return super().perform_destroy(instance)
class ContractRecordViewSet(CustomModelViewSet):
"""
list: 销售合同到款流水
销售合同到款流水
"""
perms_map = {
'get': '*',
'post': 'contract.update',
'put': 'contract.update',
'patch': 'contract.update',
'delete': 'contract.update',
}
queryset = ContractRecord.objects.all()
serializer_class = ContractRecordSerializer
search_fields = ['contract__number', 'contract__name', 'voucher_no', 'remark']
select_related_fields = ['contract', 'contract__customer', 'create_by', 'update_by']
filterset_fields = {
'contract': ['exact'],
'stage_type': ['exact', 'in'],
'pay_method': ['exact', 'in'],
}
def perform_destroy(self, instance):
if instance.contract.status == Contract.STATUS_TERMINATED:
raise ParseError('合同已终止,不可删除到款流水')
instance.delete()

View File

@ -464,6 +464,30 @@ class WfService(object):
cls.task_ticket(ticket=ticket) cls.task_ticket(ticket=ticket)
# 自动跳过连续相同审批人:如果下一个节点的处理人与当前处理人相同,自动执行同意操作
if (handler is not None
and destination_state.type not in (State.STATE_TYPE_START, State.STATE_TYPE_END)
and transition.attribute_type == Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT
and destination_participant_type == State.PARTICIPANT_TYPE_PERSONAL
and str(destination_participant) == str(handler.id)):
# 查找下一个状态的"同意"流转
next_transition = Transition.objects.filter(
is_deleted=False,
source_state=destination_state,
attribute_type=Transition.TRANSITION_ATTRIBUTE_TYPE_ACCEPT
).first()
if next_transition:
import logging
logger = logging.getLogger(__name__)
logger.info(f'工单{ticket.sn}: 连续节点审批人相同({handler.username}),自动跳过节点[{destination_state.name}]')
ticket = cls.handle_ticket(
ticket=ticket,
transition=next_transition,
new_ticket_data=ticket.ticket_data,
handler=handler,
suggestion='(系统自动审批:与上一节点审批人相同)',
)
return ticket return ticket
@classmethod @classmethod

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-03-26 08:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0127_handoverb_oinfo_json_alter_attlog_create_by_and_more'),
]
operations = [
migrations.AddField(
model_name='wmaterial',
name='is_manual',
field=models.BooleanField(default=False, verbose_name='手动创建'),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wpm", "0128_add_is_manual_to_wmaterial"),
]
operations = [
migrations.AddField(
model_name="mlogbdefect",
name="is_inherited",
field=models.BooleanField(default=False, verbose_name="是否继承"),
),
]

View File

@ -126,6 +126,21 @@ class WMaterial(CommonBDModel):
batch_ofrom = models.TextField('原料批次号', null=True, blank=True) batch_ofrom = models.TextField('原料批次号', null=True, blank=True)
material_ofrom = models.ForeignKey(Material, verbose_name='原料物料', on_delete=models.SET_NULL, null=True, blank=True, related_name='wm_mofrom') material_ofrom = models.ForeignKey(Material, verbose_name='原料物料', on_delete=models.SET_NULL, null=True, blank=True, related_name='wm_mofrom')
number_from = models.TextField("来源于个号", null=True, blank=True) number_from = models.TextField("来源于个号", null=True, blank=True)
is_manual = models.BooleanField('手动创建', default=False)
def delete(self, *args, **kwargs):
if not self.is_manual:
raise ParseError('只能删除手动创建的车间库存')
checks = [
(self.mlogb_set.exists, '存在关联的生产明细'),
(self.handoverb_wm.exists, '存在关联的交接明细'),
(self.ftestwork_set.exists, '存在关联的检验记录'),
(self.wm_mioitem.exists, '存在关联的出入库明细'),
]
for check, msg in checks:
if check():
raise ParseError(msg)
super().delete(*args, **kwargs)
@property @property
def belong_dept_or_mgroup_id(self): def belong_dept_or_mgroup_id(self):
@ -520,12 +535,51 @@ class Mlogb(BaseModel):
if mlog and cal_mlog: if mlog and cal_mlog:
mlog.cal_mlog_count_from_mlogb() mlog.cal_mlog_count_from_mlogb()
def get_default_inherited_defect(self):
if self.material_out is None or self.material_out.tracking != Material.MA_TRACKING_BATCH:
return None
if self.mlogb_from_id and self.mlogb_from and self.mlogb_from.wm_in_id:
return self.mlogb_from.wm_in.defect
if self.wm_in_id and self.wm_in:
return self.wm_in.defect
if self.mlog and self.mlog.wm_in_id:
return self.mlog.wm_in.defect
return None
def has_legacy_defect_count(self):
return any(getattr(self, f.name) > 0 for f in Mlogb._meta.fields if 'count_n_' in f.name)
def sync_inherited_defect(self, cal_count=True):
inherited_qs = MlogbDefect.objects.filter(mlogb=self, is_inherited=True)
if MlogbDefect.objects.filter(mlogb=self, is_inherited=False).exists() or self.has_legacy_defect_count():
inherited_qs.delete()
return
defect = self.get_default_inherited_defect()
if defect is None:
inherited_qs.delete()
return
count = self.count_real
inherited, _ = MlogbDefect.objects.get_or_create(
mlogb=self,
defect=defect,
is_inherited=True,
)
inherited.count = count
inherited.count_has = count
inherited.save(update_fields=["count", "count_has"])
inherited_qs.exclude(id=inherited.id).delete()
if cal_count:
self.cal_count_notok(cal_mlog=False)
class MlogbDefect(BaseModel): class MlogbDefect(BaseModel):
"""TN: 生成记录的缺陷记录""" """TN: 生成记录的缺陷记录"""
mlogb = models.ForeignKey(Mlogb, verbose_name='生产记录', on_delete=models.CASCADE) mlogb = models.ForeignKey(Mlogb, verbose_name='生产记录', on_delete=models.CASCADE)
defect = models.ForeignKey("qm.Defect", verbose_name='缺陷', on_delete=models.CASCADE, null=True, blank=True) defect = models.ForeignKey("qm.Defect", verbose_name='缺陷', on_delete=models.CASCADE, null=True, blank=True)
count = models.DecimalField('数量', default=0, max_digits=11, decimal_places=1) count = models.DecimalField('数量', default=0, max_digits=11, decimal_places=1)
count_has = models.DecimalField('含有该缺陷的数量', default=0, max_digits=11, decimal_places=1) count_has = models.DecimalField('含有该缺陷的数量', default=0, max_digits=11, decimal_places=1)
is_inherited = models.BooleanField("是否继承", default=False)
@classmethod @classmethod
def get_defect_qs(cls, ftype="all"): def get_defect_qs(cls, ftype="all"):

View File

@ -1,6 +1,6 @@
from apps.wpm.models import BatchSt from apps.wpm.models import BatchSt
import logging import logging
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect, MlogUser
from apps.mtm.models import Mgroup from apps.mtm.models import Mgroup
import decimal import decimal
from django.db.models import Sum from django.db.models import Sum
@ -27,7 +27,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
mgroup_name = mgroup.name mgroup_name = mgroup.name
mlogb1_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False, mlogb1_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False,
material_out__isnull=False, mlog__mgroup=mgroup, material_out__isnull=False, mlog__mgroup=mgroup,
mlog__is_fix=False, batch=batch, need_inout=True) mlog__is_fix=False, batch=batch, need_inout=True).order_by("mlog__submit_time")
if mlogb1_qs.exists(): if mlogb1_qs.exists():
data[f"{mgroup_name}_日期"] = [] data[f"{mgroup_name}_日期"] = []
data[f"{mgroup_name}_操作人"] = [] data[f"{mgroup_name}_操作人"] = []
@ -38,6 +38,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_ok_full"] = 0 data[f"{mgroup_name}_count_ok_full"] = 0
data[f"{mgroup_name}_count_pn_jgqbl"] = 0 data[f"{mgroup_name}_count_pn_jgqbl"] = 0
mlogb_q_ids = [] mlogb_q_ids = []
cal_mlog = []
for item in mlogb1_qs: for item in mlogb1_qs:
# 找到对应的输入 # 找到对应的输入
mlogb_from:Mlogb = item.mlogb_from mlogb_from:Mlogb = item.mlogb_from
@ -51,6 +52,13 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_pn_jgqbl"] += 0 data[f"{mgroup_name}_count_pn_jgqbl"] += 0
if item.mlog.handle_user: if item.mlog.handle_user:
data[f"{mgroup_name}_操作人"].append(item.mlog.handle_user) data[f"{mgroup_name}_操作人"].append(item.mlog.handle_user)
# 子工序操作人
if item.mlog not in cal_mlog:
mlog_users_qs = MlogUser.objects.filter(mlog=item.mlog)
if mlog_users_qs.exists():
for mlog_user in mlog_users_qs:
data[f"{mgroup_name}_{mlog_user.process.name}_操作人"] = mlog_user.handle_user.name
cal_mlog.append(item.mlog)
if item.mlog.handle_date: if item.mlog.handle_date:
data[f"{mgroup_name}_日期"].append(item.mlog.handle_date) data[f"{mgroup_name}_日期"].append(item.mlog.handle_date)
data[f"{mgroup_name}_count_real"] += item.count_real data[f"{mgroup_name}_count_real"] += item.count_real

View File

@ -207,15 +207,32 @@ class WMaterialSerializer(CustomModelSerializer):
ret['count_canhandover'] = str(Decimal(ret['count']) - Decimal(ret['count_handovering'])) ret['count_canhandover'] = str(Decimal(ret['count']) - Decimal(ret['count_handovering']))
return ret return ret
class WMaterialCreateSerializer(CustomModelSerializer):
class Meta:
model = WMaterial
fields = ['material', 'count', 'batch', 'mgroup']
extra_kwargs = {
'material': {'required': True},
'count': {'required': True},
'batch': {'required': True},
'mgroup': {'required': True, 'allow_null': False},
}
def validate(self, attrs):
attrs['belong_dept'] = attrs['mgroup'].belong_dept
return attrs
class MlogbDefectSerializer(CustomModelSerializer): class MlogbDefectSerializer(CustomModelSerializer):
defect_name = serializers.CharField(source="defect.name", read_only=True) defect_name = serializers.CharField(source="defect.name", read_only=True)
defect_okcate = serializers.CharField(source="defect.okcate", read_only=True) defect_okcate = serializers.CharField(source="defect.okcate", read_only=True)
class Meta: class Meta:
model = MlogbDefect model = MlogbDefect
fields = ["id", "defect_name", "count", "mlogb", "defect", "defect_okcate", "count_has"] fields = ["id", "defect_name", "count", "mlogb", "defect", "defect_okcate", "count_has", "is_inherited"]
read_only_fields = EXCLUDE_FIELDS_BASE + ["mlogb"] read_only_fields = EXCLUDE_FIELDS_BASE + ["mlogb"]
extra_kwargs = { extra_kwargs = {
'count_has': {'required': False}, 'count_has': {'required': False},
'is_inherited': {'required': False},
} }
def validate(self, attrs): def validate(self, attrs):
@ -461,6 +478,7 @@ class MlogSerializer(CustomModelSerializer):
if mlogb_defect_objects: if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects) MlogbDefect.objects.bulk_create(mlogb_defect_objects)
mlogb.cal_count_notok(cal_mlog=False) mlogb.cal_count_notok(cal_mlog=False)
mlogb.sync_inherited_defect(cal_count=True)
instance.cal_mlog_count_from_mlogb() instance.cal_mlog_count_from_mlogb()
return instance return instance
@ -551,7 +569,7 @@ class MlogSerializer(CustomModelSerializer):
mox.save() mox.save()
Mlogb.objects.filter(mlog=instance, material_out__isnull=False).exclude(id=mox.id).delete() Mlogb.objects.filter(mlog=instance, material_out__isnull=False).exclude(id=mox.id).delete()
if need_mdefect: if need_mdefect:
MlogbDefect.objects.filter(mlogb__mlog=instance).delete() MlogbDefect.objects.filter(mlogb__mlog=instance, is_inherited=False).delete()
mlogb_defect_objects = [ mlogb_defect_objects = [
MlogbDefect(**{**item, "mlogb": mox, "id": idWorker.get_id()}) MlogbDefect(**{**item, "mlogb": mox, "id": idWorker.get_id()})
for item in mlogdefect if item["count"] > 0 for item in mlogdefect if item["count"] > 0
@ -559,6 +577,7 @@ class MlogSerializer(CustomModelSerializer):
if mlogb_defect_objects: if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects) MlogbDefect.objects.bulk_create(mlogb_defect_objects)
mox.cal_count_notok(cal_mlog=False) mox.cal_count_notok(cal_mlog=False)
mox.sync_inherited_defect(cal_count=True)
instance.cal_mlog_count_from_mlogb() instance.cal_mlog_count_from_mlogb()
return instance return instance
@ -1095,6 +1114,8 @@ class MlogbOutUpdateSerializer(CustomModelSerializer):
if mlogb_defect_objects: if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects) MlogbDefect.objects.bulk_create(mlogb_defect_objects)
ins.cal_count_notok(cal_mlog=False) ins.cal_count_notok(cal_mlog=False)
elif ins.material_out.tracking == Material.MA_TRACKING_BATCH:
ins.sync_inherited_defect(cal_count=True)
return ins return ins
def validate(self, attrs): def validate(self, attrs):

View File

@ -11,7 +11,8 @@ from apps.system.models import User
from apps.mtm.models import Material, Process, Route, Mgroup, RoutePack, RouteMat from apps.mtm.models import Material, Process, Route, Mgroup, RoutePack, RouteMat
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
from apps.utils.mixins import CustomListModelMixin, BulkCreateModelMixin, ComplexQueryMixin, BulkDestroyModelMixin, BulkUpdateModelMixin from rest_framework.mixins import DestroyModelMixin
from apps.utils.mixins import CustomListModelMixin, CustomCreateModelMixin, BulkCreateModelMixin, ComplexQueryMixin, BulkDestroyModelMixin, BulkUpdateModelMixin
from .filters import StLogFilter, SfLogFilter, WMaterialFilter, MlogFilter, HandoverFilter, MlogbFilter, BatchStFilter, MlogbwFilter from .filters import StLogFilter, SfLogFilter, WMaterialFilter, MlogFilter, HandoverFilter, MlogbFilter, BatchStFilter, MlogbwFilter
from .models import SfLog, SfLogExp, StLog, WMaterial, Mlog, Handover, Mlogb, Mlogbw, AttLog, OtherLog, Fmlog, BatchSt, MlogbDefect, MlogUser, BatchLog, Handoverb from .models import SfLog, SfLogExp, StLog, WMaterial, Mlog, Handover, Mlogb, Mlogbw, AttLog, OtherLog, Fmlog, BatchSt, MlogbDefect, MlogUser, BatchLog, Handoverb
@ -20,6 +21,7 @@ from .serializers import (
SfLogSerializer, SfLogSerializer,
StLogSerializer, StLogSerializer,
WMaterialSerializer, WMaterialSerializer,
WMaterialCreateSerializer,
MlogRevertSerializer, MlogRevertSerializer,
MlogSerializer, MlogSerializer,
MlogRelatedSerializer, MlogRelatedSerializer,
@ -160,16 +162,19 @@ class SfLogExpViewSet(CustomListModelMixin, BulkUpdateModelMixin, CustomGenericV
filterset_fields = ["sflog", "stlog"] filterset_fields = ["sflog", "stlog"]
class WMaterialViewSet(CustomListModelMixin, CustomGenericViewSet): class WMaterialViewSet(CustomCreateModelMixin, DestroyModelMixin, CustomListModelMixin, CustomGenericViewSet):
""" """
list: 车间库存 list: 车间库存
create: 手动创建车间库存
destroy: 删除手动创建的车间库存
车间库存 车间库存
""" """
perms_map = {"get": "*"} perms_map = {"get": "*", "post": "wmaterial.create", "delete": "wmaterial.delete"}
queryset = WMaterial.objects.filter(count__gt=0) queryset = WMaterial.objects.filter(count__gt=0)
serializer_class = WMaterialSerializer serializer_class = WMaterialSerializer
create_serializer_class = WMaterialCreateSerializer
select_related_fields = ["material", "belong_dept", "material__process", "supplier"] select_related_fields = ["material", "belong_dept", "material__process", "supplier"]
search_fields = ["material__name", "material__specification", "batch", "material__model", "defect__name", "notok_sign"] search_fields = ["material__name", "material__specification", "batch", "material__model", "defect__name", "notok_sign"]
filterset_class = WMaterialFilter filterset_class = WMaterialFilter
@ -186,6 +191,12 @@ class WMaterialViewSet(CustomListModelMixin, CustomGenericViewSet):
return queryset return queryset
return queryset.exclude(state=WMaterial.WM_SCRAP) return queryset.exclude(state=WMaterial.WM_SCRAP)
def perform_create(self, serializer):
serializer.save(create_by=self.request.user, is_manual=True)
def perform_destroy(self, instance):
instance.delete()
@action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=DeptBatchSerializer) @action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=DeptBatchSerializer)
def batchs(self, request): def batchs(self, request):
"""获取车间的批次号(废弃) """获取车间的批次号(废弃)
@ -966,6 +977,8 @@ class MlogbInViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyMode
Mlogbw.objects.get_or_create(number=numberx, mlogb=mlogbout) Mlogbw.objects.get_or_create(number=numberx, mlogb=mlogbout)
else: else:
raise ParseError("不支持生成产出物料!") raise ParseError("不支持生成产出物料!")
for mlogbout in Mlogb.objects.filter(mlog=mlog, material_out__isnull=False):
mlogbout.sync_inherited_defect(cal_count=True)
mlog.cal_mlog_count_from_mlogb() mlog.cal_mlog_count_from_mlogb()
def perform_create(self, serializer): def perform_create(self, serializer):

View File

@ -1,3 +1,12 @@
## 3.1.2026033008
- feat: 新增功能
- WMaterialViewSet 添加手动来料创建和删除接口 [caoqianming]
- Ptest添加中温粘度规格和结论字段 [caoqianming]
- WMaterialViewSet 添加手动创建和删除接口 [caoqianming]
- 修改光芯OA审批的BUG与新增导出功能 [TianyangZhang]
- 恢复ichat 功能和 defaut 下的文件 [TianyangZhang]
- fix: 问题修复
- WMaterialCreateSerializer 所有字段设为必填 [caoqianming]
## 3.1.2026031316 ## 3.1.2026031316
- feat: 新增功能 - feat: 新增功能
- 删除-ichat [TianyangZhang] - 删除-ichat [TianyangZhang]

BIN
media/default/abc.mp3 Normal file

Binary file not shown.

BIN
media/default/abc2.mp3 Normal file

Binary file not shown.

BIN
media/default/alarm.mp3 Normal file

Binary file not shown.

BIN
media/default/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -35,7 +35,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, 'apps'))
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
SYS_NAME = '星途工厂综合管理系统' SYS_NAME = '星途工厂综合管理系统'
SYS_VERSION = '3.1.2026031316' SYS_VERSION = '3.1.2026033008'
X_FRAME_OPTIONS = 'SAMEORIGIN' X_FRAME_OPTIONS = 'SAMEORIGIN'
# Application definition # Application definition
@ -63,7 +63,7 @@ INSTALLED_APPS = [
'apps.wf', 'apps.wf',
'apps.ecm', 'apps.ecm',
'apps.hrm', 'apps.hrm',
'apps.ichat', #'apps.ichat',
'apps.am', 'apps.am',
'apps.vm', 'apps.vm',
'apps.rpm', 'apps.rpm',