This commit is contained in:
caoqianming 2026-03-13 16:35:48 +08:00
commit 409972ba88
34 changed files with 946 additions and 662 deletions

View File

@ -9,7 +9,6 @@ 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),
('hrm', '0029_auto_20260205_1337'),

View File

@ -357,7 +357,7 @@ class EmpContract(CommonAModel):
employee = models.OneToOneField(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True)
ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单',
on_delete=models.CASCADE, related_name='contract_ticket', null=True, blank=True)
counts = models.PositiveSmallIntegerField('合同变更次数', default=1)
counts = models.PositiveSmallIntegerField('合同变更次数', default=0)
plan_renewal = models.DateField('应续签', null=True, blank=True)
normal_renewal = models.DateField('正常续签', null=True, blank=True)
change_date = models.IntegerField('续签/变更(年)', null=True, blank=True)

View File

@ -421,8 +421,8 @@ class EmpContractSerializer(CustomModelSerializer):
post_name = serializers.CharField(source="employee.post.name", read_only=True)
dept_name = serializers.CharField(source='employee.belong_dept.name', read_only=True)
gender = serializers.CharField(source="employee.gender", read_only=True)
join_date = serializers.CharField(source="employee.join_date", read_only=True)
end_contract = serializers.CharField(source="employee.end_contract_date", read_only=True)
join_date = serializers.CharField(source="employee.start_date", read_only=True)
end_contract = serializers.CharField(source="employee.contract_end_date", read_only=True)
class Meta:
model = EmpContract
fields = '__all__'

View File

@ -20,7 +20,7 @@ router.register('empperson', EmpPersonInfoViewSet, basename='empperson')
router.register('leave', LeaveViewSet, basename='leave')
router.register('transfer', TransferViewSet, basename='transfer')
router.register('probation', ProbationViewSet, basename='probation')
router.register('contract', EmpContractViewSet, basename='emp_contract')
router.register('contract', EmpContractViewSet, basename='empcontract')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
]

View File

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

View File

@ -1,48 +0,0 @@
# 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

@ -1,17 +0,0 @@
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

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

View File

@ -1,53 +0,0 @@
# 角色
你是一位资深的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

View File

@ -1,22 +0,0 @@
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')

View File

@ -1,18 +0,0 @@
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

View File

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

View File

@ -1,16 +0,0 @@
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')
]

View File

@ -1,88 +0,0 @@
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 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()

View File

@ -1,87 +0,0 @@
import requests
from langchain_core.language_models import LLM
from langchain_core.outputs import LLMResult, Generation
from langchain_experimental.sql import SQLDatabaseChain
from langchain_community.utilities import SQLDatabase
from server.conf import DATABASES
from apps.ichat.serializers import CustomLLMrequestSerializer
from rest_framework.views import APIView
from urllib.parse import quote_plus
from rest_framework.response import Response
db_conf = DATABASES['default']
# 密码需要 URL 编码(因为有特殊字符如 @
password_encodeed = quote_plus(db_conf['PASSWORD'])
db = SQLDatabase.from_uri(f"postgresql+psycopg2://{db_conf['USER']}:{password_encodeed}@{db_conf['HOST']}/{db_conf['NAME']}", include_tables=["enm_mpoint", "enm_mpointstat"])
# model_url = "http://14.22.88.72:11025/v1/chat/completions"
model_url = "http://139.159.180.64:11434/v1/chat/completions"
class CustomLLM(LLM):
model_url: str
mode: str = 'chat'
def _call(self, prompt: str, stop: list = None) -> str:
data = {
"model":"glm4",
"messages": self.build_message(prompt),
"stream": False,
}
response = requests.post(self.model_url, json=data, timeout=600)
response.raise_for_status()
content = response.json()["choices"][0]["message"]["content"]
print('content---', content)
clean_sql = self.strip_sql_markdown(content) if self.mode == 'sql' else content.strip()
return clean_sql
def _generate(self, prompts: list, stop: list = None) -> LLMResult:
generations = []
for prompt in prompts:
text = self._call(prompt, stop)
generations.append([Generation(text=text)])
return LLMResult(generations=generations)
def strip_sql_markdown(self, content: str) -> str:
import re
# 去掉包裹在 ```sql 或 ``` 中的内容
match = re.search(r"```sql\s*(.*?)```", content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
else:
return content.strip()
def build_message(self, prompt: str) -> list:
if self.mode == 'sql':
system_prompt = (
"你是一个 SQL 助手,严格遵循以下规则:\n"
"1. 只返回 PostgreSQL 语法 SQL 语句。\n"
"2. 严格禁止添加任何解释、注释、Markdown 代码块标记(包括 ```sql 和 ```)。\n"
"3. 输出必须是纯 SQL且可直接执行无需任何额外处理。\n"
"4. 在 SQL 中如有多个表,请始终使用表名前缀引用字段,避免字段歧义。"
)
else:
system_prompt = "你是一个聊天助手,请根据用户的问题,提供简洁明了的答案。"
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
]
@property
def _llm_type(self) -> str:
return "custom_llm"
class QueryLLMview(APIView):
def post(self, request):
serializer = CustomLLMrequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
prompt = serializer.validated_data['prompt']
mode = serializer.validated_data.get('mode', 'chat')
llm = CustomLLM(model_url=model_url, mode=mode)
print('prompt---', prompt, mode)
if mode == 'sql':
chain = SQLDatabaseChain.from_llm(llm, db, verbose=True)
result = chain.invoke(prompt)
else:
result = llm._call(prompt)
return Response({"result": result})

View File

@ -1,155 +0,0 @@
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}, {"role":"system" , "content": "只返回一个结果'database''general'"}],
"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":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':'*'}

View File

@ -1,129 +0,0 @@
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,6 +1,6 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
class MprConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.ichat'
name = 'apps.mpr'

39
apps/mpr/filters.py Normal file
View File

@ -0,0 +1,39 @@
from django_filters import rest_framework as filters
from apps.mpr.models import PurchaseRequisition, WarehouseEntry, WarehouseStock, MaterialRequisition
class PurchaseRequisitionFilter(filters.FilterSet):
req_date_after = filters.DateFilter(field_name='req_date', lookup_expr='gte')
req_date_before = filters.DateFilter(field_name='req_date', lookup_expr='lte')
class Meta:
model = PurchaseRequisition
fields = ['belong_dept', 'req_date_after', 'req_date_before']
class WarehouseEntryFilter(filters.FilterSet):
entry_date_after = filters.DateFilter(field_name='entry_date', lookup_expr='gte')
entry_date_before = filters.DateFilter(field_name='entry_date', lookup_expr='lte')
class Meta:
model = WarehouseEntry
fields = ['warehouse', 'entry_type', 'entry_method', 'entry_date_after', 'entry_date_before']
class WarehouseStockFilter(filters.FilterSet):
entry_date_after = filters.DateFilter(field_name='entry_date', lookup_expr='gte')
entry_date_before = filters.DateFilter(field_name='entry_date', lookup_expr='lte')
class Meta:
model = WarehouseStock
fields = ['warehouse', 'entry_type', 'entry_method', 'supplier_name',
'invoice_received', 'status', 'entry_date_after', 'entry_date_before']
class MaterialRequisitionFilter(filters.FilterSet):
req_date_after = filters.DateFilter(field_name='req_date', lookup_expr='gte')
req_date_before = filters.DateFilter(field_name='req_date', lookup_expr='lte')
class Meta:
model = MaterialRequisition
fields = ['belong_dept', 'req_date_after', 'req_date_before']

View File

@ -0,0 +1,64 @@
# Generated by Django 3.2.12 on 2026-03-12 03:26
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 = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wf', '0006_auto_20251215_1645'),
]
operations = [
migrations.CreateModel(
name='PurchaseRequisition',
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='编号')),
('phone', models.CharField(blank=True, max_length=20, 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='合计金额')),
('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='所属部门')),
('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='创建人')),
('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='最后编辑人')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='PurchaseRequisitionItem',
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='删除标记')),
('item_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='单位')),
('req_quantity', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='申购数量')),
('current_stock', models.DecimalField(decimal_places=3, default=0, max_digits=12, verbose_name='现库存量')),
('need_date', models.DateField(blank=True, null=True, verbose_name='需用日期')),
('purchase_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='单价')),
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=14, 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.purchaserequisition', verbose_name='关联申购单')),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,65 @@
# 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

@ -0,0 +1,42 @@
# 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

@ -0,0 +1,60 @@
# 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

@ -0,0 +1,18 @@
# 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

182
apps/mpr/models.py Normal file
View File

@ -0,0 +1,182 @@
from django.db import models
from apps.utils.models import BaseModel, CommonBDModel
from datetime import datetime
from django.db.models import Max, Sum
def _get_number(model_cls):
today_str = datetime.now().strftime('%Y%m%d')
prefix = model_cls.PREFIX
last_record = model_cls.objects.filter(
number__startswith=f"{prefix}-{today_str}"
).aggregate(Max('number'))['number__max']
if last_record:
last_number = int(last_record.split('-')[-1]) + 1
else:
last_number = 1
return f"{prefix}-{today_str}-{last_number:04d}"
class PurchaseRequisition(CommonBDModel):
"""
TN:物资申购单
"""
PREFIX = 'WZSG'
number = models.CharField('编号', max_length=20, unique=True)
phone = models.CharField('联系电话', max_length=20, null=True, blank=True)
req_date = models.DateField('申购日期', null=True, blank=True)
total_amount = models.DecimalField('合计金额', max_digits=14, decimal_places=2, default=0)
note = models.TextField('备注', null=True, blank=True)
ticket = models.OneToOneField(
'wf.ticket', verbose_name='关联工单',
on_delete=models.SET_NULL, null=True, blank=True,
related_name='mpr_ticket')
@classmethod
def get_a_number(cls):
return _get_number(cls)
class PurchaseRequisitionItem(BaseModel):
"""
TN:物资申购明细
"""
requisition = models.ForeignKey(
PurchaseRequisition, verbose_name='关联申购单',
on_delete=models.CASCADE, related_name='items')
item_name = models.CharField('物品名称', max_length=100)
spec = models.CharField('规格及型号', max_length=200, null=True, blank=True)
unit = models.CharField('单位', max_length=20, null=True, blank=True)
req_quantity = models.DecimalField('申购数量', max_digits=12, decimal_places=3, default=0)
current_stock = models.DecimalField('现库存量', max_digits=12, decimal_places=3, default=0)
need_date = models.DateField('需用日期', null=True, blank=True)
purchase_quantity = models.DecimalField('需采购数量', max_digits=12, decimal_places=3, default=0)
unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0)
total_price = models.DecimalField('总价', max_digits=14, decimal_places=2, default=0)
note = models.TextField('备注', null=True, blank=True)
class WarehouseEntry(CommonBDModel):
"""
TN:仓库入库单
"""
PREFIX = 'CKRK'
ENTRY_TYPE_CHOICES = (
('raw_normal', '原材料正常入库'),
('raw_estimated', '原材料暂估入库'),
('product', '产品入库'),
('other', '其他'),
)
ENTRY_METHOD_CHOICES = (
('purchase', '采购'),
('self_made', '自制'),
('other', '其他'),
)
number = models.CharField('编号', max_length=20, unique=True)
warehouse = models.ForeignKey(
'inm.WareHouse', verbose_name='仓库',
on_delete=models.CASCADE, related_name='entries')
entry_date = models.DateField('入库日期', null=True, blank=True)
entry_type = models.CharField('入库类型', max_length=20, choices=ENTRY_TYPE_CHOICES, default='raw_normal')
entry_method = models.CharField('入库方式', max_length=20, choices=ENTRY_METHOD_CHOICES, default='purchase')
total_amount = models.DecimalField('合计金额', max_digits=14, decimal_places=2, default=0)
note = models.TextField('备注', null=True, blank=True)
ticket = models.OneToOneField(
'wf.ticket', verbose_name='关联工单',
on_delete=models.SET_NULL, null=True, blank=True,
related_name='warehouse_entry_ticket')
@classmethod
def get_a_number(cls):
return _get_number(cls)
class WarehouseEntryItem(BaseModel):
"""
TN:入库明细
"""
entry = models.ForeignKey(
WarehouseEntry, verbose_name='关联入库单',
on_delete=models.CASCADE, related_name='items')
name = models.CharField('名称', max_length=100)
spec = models.CharField('规格', max_length=200, null=True, blank=True)
unit = models.CharField('单位', max_length=20, null=True, blank=True)
quantity = models.DecimalField('数量', max_digits=12, decimal_places=3, default=0)
unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0)
amount = models.DecimalField('金额', max_digits=14, decimal_places=2, default=0)
supplier_name = models.CharField('供应商名称', max_length=100, null=True, blank=True)
invoice_received = models.BooleanField('账单是否收到', default=False)
note = models.TextField('备注', null=True, blank=True)
class WarehouseStock(BaseModel):
"""
TN:物料库存审批通过后入库
"""
STATUS_CHOICES = (
('idle', '闲置'),
('in_requisition', '领用中'),
('requisitioned', '已领用'),
)
warehouse = models.ForeignKey(
'inm.WareHouse', verbose_name='仓库',
on_delete=models.CASCADE, related_name='mpr_stocks')
entry = models.ForeignKey(
WarehouseEntry, verbose_name='来源入库单',
on_delete=models.SET_NULL, null=True, blank=True, related_name='stocks')
entry_number = models.CharField('入库单号', max_length=20)
entry_date = models.DateField('入库日期', null=True, blank=True)
entry_type = models.CharField('入库类型', max_length=20, choices=WarehouseEntry.ENTRY_TYPE_CHOICES)
entry_method = models.CharField('入库方式', max_length=20, choices=WarehouseEntry.ENTRY_METHOD_CHOICES)
name = models.CharField('名称', max_length=100)
spec = models.CharField('规格', max_length=200, null=True, blank=True)
unit = models.CharField('单位', max_length=20, null=True, blank=True)
quantity = models.DecimalField('数量', max_digits=12, decimal_places=3, default=0)
unit_price = models.DecimalField('单价', max_digits=14, decimal_places=2, default=0)
amount = models.DecimalField('金额', max_digits=14, decimal_places=2, default=0)
supplier_name = models.CharField('供应商名称', max_length=100, null=True, blank=True)
invoice_received = models.BooleanField('账单是否收到', default=False)
status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='idle')
class MaterialRequisition(CommonBDModel):
"""
TN:物资领用单
"""
PREFIX = 'WZLY'
number = models.CharField('编号', max_length=20, unique=True)
req_date = models.DateField('填报时间', null=True, blank=True)
collector = models.CharField('领取人', max_length=50, null=True, blank=True)
note = models.TextField('备注', null=True, blank=True)
ticket = models.OneToOneField(
'wf.ticket', verbose_name='关联工单',
on_delete=models.SET_NULL, null=True, blank=True,
related_name='material_requisition_ticket')
@classmethod
def get_a_number(cls):
return _get_number(cls)
class MaterialRequisitionItem(BaseModel):
"""
TN:物资领用明细
"""
requisition = models.ForeignKey(
MaterialRequisition, verbose_name='关联领用单',
on_delete=models.CASCADE, related_name='items')
is_stock_item = models.BooleanField('是否库存物品', default=True)
stock = models.ForeignKey(
WarehouseStock, verbose_name='关联库存',
on_delete=models.SET_NULL, null=True, blank=True, related_name='requisition_items')
req_type = models.CharField('领用类型', max_length=50, null=True, blank=True)
name = models.CharField('物资名称', max_length=100)
spec = models.CharField('规格型号', max_length=200, null=True, blank=True)
unit = models.CharField('单位', max_length=20, null=True, blank=True)
quantity = models.DecimalField('领用量', max_digits=12, decimal_places=3, default=0)
note = models.TextField('备注', null=True, blank=True)

271
apps/mpr/serializers.py Normal file
View File

@ -0,0 +1,271 @@
from decimal import Decimal
from rest_framework import serializers
from rest_framework.exceptions import ParseError
from apps.utils.serializers import CustomModelSerializer
from apps.utils.constants import EXCLUDE_FIELDS
from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem,
)
from apps.wf.serializers import TicketSimpleSerializer
# ========== 物资申购单 ==========
class PurchaseRequisitionItemSerializer(CustomModelSerializer):
class Meta:
model = PurchaseRequisitionItem
fields = '__all__'
read_only_fields = ['create_time', 'update_time', 'is_deleted']
class PurchaseRequisitionListSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
class Meta:
model = PurchaseRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class PurchaseRequisitionDetailSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
items_ = PurchaseRequisitionItemSerializer(source='items', many=True, read_only=True)
class Meta:
model = PurchaseRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class PurchaseRequisitionCreateSerializer(CustomModelSerializer):
items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[])
class Meta:
model = PurchaseRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number', 'total_amount']
def create(self, validated_data):
items_data = validated_data.pop('items', [])
validated_data['number'] = PurchaseRequisition.get_a_number()
instance = super().create(validated_data)
self._save_items(instance, items_data)
return instance
def update(self, instance, validated_data):
items_data = validated_data.pop('items', None)
instance = super().update(instance, validated_data)
if items_data is not None:
instance.items.all().delete()
self._save_items(instance, items_data)
return instance
def _save_items(self, instance, items_data):
total = 0
for item in items_data:
item.pop('id', None)
unit_price = float(item.get('unit_price', 0) or 0)
purchase_quantity = float(item.get('purchase_quantity', 0) or 0)
item['total_price'] = round(unit_price * purchase_quantity, 2)
total += item['total_price']
PurchaseRequisitionItem.objects.create(requisition=instance, **item)
instance.total_amount = total
instance.save(update_fields=['total_amount'])
# ========== 仓库入库单 ==========
class WarehouseEntryItemSerializer(CustomModelSerializer):
class Meta:
model = WarehouseEntryItem
fields = '__all__'
read_only_fields = ['create_time', 'update_time', 'is_deleted']
class WarehouseEntryListSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True)
entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True)
class Meta:
model = WarehouseEntry
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class WarehouseEntryDetailSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
items_ = WarehouseEntryItemSerializer(source='items', many=True, read_only=True)
entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True)
entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True)
class Meta:
model = WarehouseEntry
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class WarehouseEntryCreateSerializer(CustomModelSerializer):
items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[])
class Meta:
model = WarehouseEntry
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number', 'total_amount']
def create(self, validated_data):
items_data = validated_data.pop('items', [])
validated_data['number'] = WarehouseEntry.get_a_number()
instance = super().create(validated_data)
self._save_items(instance, items_data)
return instance
def update(self, instance, validated_data):
items_data = validated_data.pop('items', None)
instance = super().update(instance, validated_data)
if items_data is not None:
instance.items.all().delete()
self._save_items(instance, items_data)
return instance
def _save_items(self, instance, items_data):
total = 0
for item in items_data:
item.pop('id', None)
unit_price = float(item.get('unit_price', 0) or 0)
quantity = float(item.get('quantity', 0) or 0)
item['amount'] = round(unit_price * quantity, 2)
total += item['amount']
WarehouseEntryItem.objects.create(entry=instance, **item)
instance.total_amount = total
instance.save(update_fields=['total_amount'])
# ========== 物料库存 ==========
class WarehouseStockSerializer(CustomModelSerializer):
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
entry_type_display = serializers.CharField(source='get_entry_type_display', read_only=True)
entry_method_display = serializers.CharField(source='get_entry_method_display', read_only=True)
status_display = serializers.SerializerMethodField()
class Meta:
model = WarehouseStock
fields = '__all__'
read_only_fields = ['create_time', 'update_time', 'is_deleted']
def get_status_display(self, obj):
if obj.quantity <= 0:
return '已领完'
return obj.get_status_display()
# ========== 物资领用单 ==========
class MaterialRequisitionItemSerializer(CustomModelSerializer):
stock_name = serializers.CharField(source='stock.name', read_only=True, default='')
class Meta:
model = MaterialRequisitionItem
fields = '__all__'
read_only_fields = ['create_time', 'update_time', 'is_deleted']
class MaterialRequisitionListSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
class Meta:
model = MaterialRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class MaterialRequisitionDetailSerializer(CustomModelSerializer):
create_by_name = serializers.CharField(source='create_by.name', read_only=True)
belong_dept_name = serializers.CharField(source='belong_dept.name', read_only=True)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
items_ = MaterialRequisitionItemSerializer(source='items', many=True, read_only=True)
class Meta:
model = MaterialRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
class MaterialRequisitionCreateSerializer(CustomModelSerializer):
items = serializers.ListField(child=serializers.DictField(), write_only=True, required=False, default=[])
class Meta:
model = MaterialRequisition
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS + ['belong_dept', 'number']
def create(self, validated_data):
items_data = validated_data.pop('items', [])
validated_data['number'] = MaterialRequisition.get_a_number()
instance = super().create(validated_data)
self._save_items(instance, items_data)
return instance
def update(self, instance, validated_data):
items_data = validated_data.pop('items', None)
instance = super().update(instance, validated_data)
if items_data is not None:
self._restore_stock(instance)
instance.items.all().delete()
self._save_items(instance, items_data)
return instance
def _save_items(self, instance, items_data):
for item in items_data:
item.pop('id', None)
is_stock_item = item.get('is_stock_item', True)
stock_id = item.pop('stock', None) or item.pop('stock_id', None)
quantity = Decimal(str(item.get('quantity', 0) or 0))
stock_obj = None
if is_stock_item and stock_id:
try:
stock_obj = WarehouseStock.objects.select_for_update().get(id=stock_id)
except WarehouseStock.DoesNotExist:
raise ParseError(f"库存记录不存在: {stock_id}")
if stock_obj.quantity < quantity:
raise ParseError(f"库存不足: {stock_obj.name} 库存{stock_obj.quantity} < 领用{quantity}")
stock_obj.quantity -= quantity
stock_obj.status = 'in_requisition'
stock_obj.save(update_fields=['quantity', 'status'])
MaterialRequisitionItem.objects.create(
requisition=instance,
is_stock_item=is_stock_item,
stock=stock_obj,
req_type=item.get('req_type', ''),
name=item.get('name', ''),
spec=item.get('spec', ''),
unit=item.get('unit', ''),
quantity=quantity,
note=item.get('note', ''),
)
@staticmethod
def _restore_stock(instance):
"""恢复库存(用于编辑或拒绝时)"""
for item in instance.items.filter(is_stock_item=True, stock__isnull=False):
stock = WarehouseStock.objects.select_for_update().get(id=item.stock_id)
stock.quantity += item.quantity
stock.status = 'idle'
stock.save(update_fields=['quantity', 'status'])

0
apps/mpr/tests.py Normal file
View File

23
apps/mpr/urls.py Normal file
View File

@ -0,0 +1,23 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.mpr.views import (
PurchaseRequisitionViewSet, PurchaseRequisitionItemViewSet,
WarehouseEntryViewSet, WarehouseEntryItemViewSet,
WarehouseStockViewSet,
MaterialRequisitionViewSet, MaterialRequisitionItemViewSet,
)
API_BASE_URL = 'api/mpr/'
router = DefaultRouter()
router.register('requisition', PurchaseRequisitionViewSet, basename='requisition')
router.register('requisition_item', PurchaseRequisitionItemViewSet, basename='requisition_item')
router.register('warehouse_entry', WarehouseEntryViewSet, basename='warehouse_entry')
router.register('warehouse_entry_item', WarehouseEntryItemViewSet, basename='warehouse_entry_item')
router.register('warehouse_stock', WarehouseStockViewSet, basename='warehouse_stock')
router.register('material_requisition', MaterialRequisitionViewSet, basename='material_requisition')
router.register('material_requisition_item', MaterialRequisitionItemViewSet, basename='material_requisition_item')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
]

173
apps/mpr/views.py Normal file
View File

@ -0,0 +1,173 @@
from rest_framework.exceptions import ParseError
from django.db import transaction
from apps.utils.viewsets import CustomModelViewSet
from apps.wf.mixins import TicketMixin
from apps.wf.models import Ticket
from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem,
)
from apps.mpr.serializers import (
PurchaseRequisitionListSerializer,
PurchaseRequisitionDetailSerializer,
PurchaseRequisitionCreateSerializer,
PurchaseRequisitionItemSerializer,
WarehouseEntryListSerializer,
WarehouseEntryDetailSerializer,
WarehouseEntryCreateSerializer,
WarehouseEntryItemSerializer,
WarehouseStockSerializer,
MaterialRequisitionListSerializer,
MaterialRequisitionDetailSerializer,
MaterialRequisitionCreateSerializer,
MaterialRequisitionItemSerializer,
)
from apps.mpr.filters import (
PurchaseRequisitionFilter, WarehouseEntryFilter,
WarehouseStockFilter, MaterialRequisitionFilter,
)
class PurchaseRequisitionViewSet(TicketMixin, CustomModelViewSet):
"""
物资申购单
"""
queryset = PurchaseRequisition.objects.all()
serializer_class = PurchaseRequisitionListSerializer
retrieve_serializer_class = PurchaseRequisitionDetailSerializer
create_serializer_class = PurchaseRequisitionCreateSerializer
update_serializer_class = PurchaseRequisitionCreateSerializer
select_related_fields = ['create_by', 'belong_dept', 'ticket', 'ticket__state']
search_fields = ['number', 'create_by__name']
filterset_class = PurchaseRequisitionFilter
ordering = '-create_time'
workflow_key = 'wf_mpr'
def gen_other_ticket_data(self, instance):
dept_name = instance.belong_dept.name if instance.belong_dept else ''
return {"dept_name": dept_name}
class PurchaseRequisitionItemViewSet(CustomModelViewSet):
"""
物资申购明细
"""
queryset = PurchaseRequisitionItem.objects.all()
serializer_class = PurchaseRequisitionItemSerializer
filterset_fields = ['requisition']
ordering = 'create_time'
class WarehouseEntryViewSet(TicketMixin, CustomModelViewSet):
"""
仓库入库单
"""
queryset = WarehouseEntry.objects.all()
serializer_class = WarehouseEntryListSerializer
retrieve_serializer_class = WarehouseEntryDetailSerializer
create_serializer_class = WarehouseEntryCreateSerializer
update_serializer_class = WarehouseEntryCreateSerializer
select_related_fields = ['create_by', 'belong_dept', 'warehouse', 'ticket', 'ticket__state']
search_fields = ['number', 'create_by__name', 'warehouse__name']
filterset_class = WarehouseEntryFilter
ordering = '-create_time'
workflow_key = 'wf_warehouse_entry'
def gen_other_ticket_data(self, instance):
return {"warehouse_name": instance.warehouse.name if instance.warehouse else ''}
@staticmethod
def approve_entry(ticket: Ticket, transition, new_ticket_data: dict):
"""审批通过后,将入库明细写入物料库存"""
entry: WarehouseEntry = WarehouseEntry.objects.get(ticket=ticket)
if WarehouseStock.objects.filter(entry=entry).exists():
raise ParseError('该入库单已入库,不可重复操作')
for item in entry.items.all():
WarehouseStock.objects.create(
warehouse=entry.warehouse,
entry=entry,
entry_number=entry.number,
entry_date=entry.entry_date,
entry_type=entry.entry_type,
entry_method=entry.entry_method,
name=item.name,
spec=item.spec,
unit=item.unit,
quantity=item.quantity,
unit_price=item.unit_price,
amount=item.amount,
supplier_name=item.supplier_name,
invoice_received=item.invoice_received,
)
class WarehouseEntryItemViewSet(CustomModelViewSet):
"""
入库明细
"""
queryset = WarehouseEntryItem.objects.all()
serializer_class = WarehouseEntryItemSerializer
filterset_fields = ['entry']
ordering = 'create_time'
class WarehouseStockViewSet(CustomModelViewSet):
"""
物料库存
"""
queryset = WarehouseStock.objects.all()
serializer_class = WarehouseStockSerializer
select_related_fields = ['warehouse', 'entry']
search_fields = ['name', 'spec', 'supplier_name', 'entry_number']
filterset_class = WarehouseStockFilter
ordering = '-create_time'
perms_map = {'get': '*', 'post': 'warehouse_stock.create',
'put': 'warehouse_stock.update', 'delete': 'warehouse_stock.delete'}
class MaterialRequisitionViewSet(TicketMixin, CustomModelViewSet):
"""
物资领用单
"""
queryset = MaterialRequisition.objects.all()
serializer_class = MaterialRequisitionListSerializer
retrieve_serializer_class = MaterialRequisitionDetailSerializer
create_serializer_class = MaterialRequisitionCreateSerializer
update_serializer_class = MaterialRequisitionCreateSerializer
select_related_fields = ['create_by', 'belong_dept', 'ticket', 'ticket__state']
search_fields = ['number', 'create_by__name', 'collector']
filterset_class = MaterialRequisitionFilter
ordering = '-create_time'
workflow_key = 'wf_material_requis'
def gen_other_ticket_data(self, instance):
dept_name = instance.belong_dept.name if instance.belong_dept else ''
return {"dept_name": dept_name, "collector": instance.collector or ''}
@staticmethod
def approve_requisition(ticket: Ticket, transition, new_ticket_data: dict):
"""审批通过后,将库存物品状态改为已领用"""
req = MaterialRequisition.objects.get(ticket=ticket)
for item in req.items.filter(is_stock_item=True, stock__isnull=False):
stock = WarehouseStock.objects.select_for_update().get(id=item.stock_id)
stock.status = 'requisitioned'
stock.save(update_fields=['status'])
@staticmethod
def reject_requisition(ticket: Ticket, transition, new_ticket_data: dict):
"""审批拒绝后,恢复库存数量和状态"""
from apps.mpr.serializers import MaterialRequisitionCreateSerializer
req = MaterialRequisition.objects.get(ticket=ticket)
MaterialRequisitionCreateSerializer._restore_stock(req)
class MaterialRequisitionItemViewSet(CustomModelViewSet):
"""
物资领用明细
"""
queryset = MaterialRequisitionItem.objects.all()
serializer_class = MaterialRequisitionItemSerializer
filterset_fields = ['requisition']
ordering = 'create_time'

View File

@ -88,7 +88,8 @@ INSTALLED_APPS = [
'apps.ofm',
'apps.srm',
'apps.asm',
'apps.rem'
'apps.rem',
'apps.mpr'
]
MIDDLEWARE = [

View File

@ -45,7 +45,6 @@ urlpatterns = [
# api
path('', include('apps.auth1.urls')),
path('', include('apps.ichat.urls')),
path('', include('apps.system.urls')),
path('', include('apps.monitor.urls')),
path('', include('apps.wf.urls')),
@ -79,6 +78,7 @@ urlpatterns = [
path('', include('apps.srm.urls')),
path('', include('apps.asm.urls')),
path('', include('apps.rem.urls')),
path('', include('apps.mpr.urls')),
# 前端页面入口
path('', TemplateView.as_view(template_name="index.html")),