Compare commits

..

No commits in common. "master" and "3.1.2026033008" have entirely different histories.

45 changed files with 295 additions and 1970 deletions

View File

@ -1,65 +0,0 @@
---
description: 生成 changelog、更新 SYS_VERSION、打 tag 并推到远端
---
发布一个新版本,遵循以下既定流程,**严格按顺序执行**,不要省略或调整步骤。
## 步骤
### 1. 生成 changelog 与版本号
执行:
```bash
bash update_changelog.sh
```
脚本会:
- 在 `changelog.md` 顶部写入 `## 3.1.YYYYMMDDHH`,并附上自上一个 tag 起的 feat / fix / other 提交。
- 终端输出形如 `当前版本号: 3.1.YYYYMMDDHH (请手动修改)`
从输出中**抓取版本号**(即 `3.1.YYYYMMDDHH`),后续步骤记作 `<VER>`
### 2. 更新 `server/settings.py``SYS_VERSION`
`SYS_VERSION = '<旧版本>'` 改为 `SYS_VERSION = '<VER>'`
> 该字段是项目里唯一权威的版本号,前端 `/swagger` 也读它。
### 3. 检查 changelog 顶部内容
`Read` 一下 `changelog.md` 头 ~20 行,确认:
- 顶部是 `## <VER>`
- 自上次 tag 之后的提交都被分类列出。
- 如有内容明显不对(例如重复段、无关分类),先与用户确认,不要直接打 tag。
### 4. 提交 + 打 tag + 推送
参考过往风格commit message 用 `release: <VER>`tag 名直接用版本号、无 `v` 前缀):
```bash
git add changelog.md server/settings.py
git commit -m "release: <VER>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
git tag <VER>
git push
git push origin <VER>
```
可以串成一个 `&&` 链一次执行。
### 5. 汇报
向用户简述:
- 本次版本号 `<VER>`
- changelog 顶部新增了多少行 / 包含哪几类提交。
- commit hash、tag 名、master / tag 都已推到 origin。
## 注意事项
- **不要自己编版本号**:始终用 `update_changelog.sh` 输出里的那一个,不要试图改格式或日期。
- **`SYS_VERSION` 是必须改的**:脚本只更新 `changelog.md`settings 必须手动同步,否则上线后接口返回的版本号还是旧的。
- **CRLF 警告可忽略**Windows 上 `git commit` 会提示 `LF will be replaced by CRLF`,不是错误。
- **打 tag 前确认 working tree 干净**:除了本次 commit 的两个文件外,不应有其他未追踪/未提交的改动混进 release commit。如有先单独 commit 或 stash。
- **失败回滚**:如果 push 失败需要回退,删除本地 tag (`git tag -d <VER>`) 与撤销 commit (`git reset --soft HEAD^`);远端 tag 已 push 的删除需要先与用户确认。

1
.gitignore vendored
View File

@ -27,4 +27,3 @@ temp/*
nohup.out
*.zip
scripts/*.py
.claude/settings.local.json

View File

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

View File

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

View File

@ -114,185 +114,22 @@ def db_ins_mplogx():
@shared_task(base=CustomTask)
def cal_mpointstats_duration(start_time: str, end_time: str, m_code_list=[], cal_attrs=[], mpoint_stat=False):
def cal_mpointstats_duration(start_time: str, end_time: str, m_code_list=[], cal_attrs=[]):
"""
重跑某一段时间的任务
mpoint_stat: True时从已有的MpointStat hour记录开始重算(跳过MpLogx)只重算day/month/year/sflog聚合速度更快
"""
mytz = tz.gettz(settings.TIME_ZONE)
start_time = datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
start_time = start_time.replace(tzinfo=mytz)
start_time.replace(tzinfo=mytz)
end_time = datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
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
while current_time <= end_time:
year, month, day, hour = current_time.year, current_time.month, current_time.day, current_time.hour
cal_mpointstats(0, year, month, day, hour, m_code_list, cal_attrs)
myLogger.info("now: {} cal_mpointstats completed: {}".format(datetime.datetime.now(), current_time))
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
start_time.replace(tzinfo=mytz)
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)
cal_mpointstats(0, year, month, day, hour, m_code_list, cal_attrs)
myLogger.info("now: {} cal_mpointstats completed: {}".format(datetime.datetime.now(), current_time))
current_time += datetime.timedelta(hours=1)
myLogger.info("_recal_from_mpointstat completed all")
@shared_task(base=CustomTask)
def correct_bill_date():

View File

@ -378,14 +378,10 @@ class MpointStatViewSet(BulkCreateModelMixin, BulkDestroyModelMixin, CustomListM
重新运行某段时间的enm计算
"""
sr = ReCalSerializer(data=request.data)
data = request.data
sr = ReCalSerializer(data=data)
sr.is_valid(raise_exception=True)
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)
)
task = cal_mpointstats_duration.delay(data["start_time"], data["end_time"])
return Response({"task_id": task.task_id})
@action(methods=["get"], detail=False, perms_map={"get": "*"})

View File

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

View File

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

View File

@ -387,39 +387,20 @@ 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']):
raise ParseError(f'{row_num}行,身份证号格式不正确')
# 查找或创建/补全
# 查找或更新
try:
with transaction.atomic():
# 优先按身份证号匹配,匹配不到再按姓名匹配
existing = None
if id_number:
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
obj, created = Employee.objects.update_or_create(
id_number=id_number,
name=name,
defaults=data
)
except Exception as e:
raise
if created:
myLogger.info(f"✅ 第{row_num}行新增成功:{name}")
else:
myLogger.info(f"✅ 第{row_num}行更新成功:{name}")
success += 1

View File

@ -10,7 +10,6 @@ from apps.utils.serializers import CustomModelSerializer
from apps.mtm.models import Material
from .models import MIO, MaterialBatch, MIOItem, WareHouse, MIOItemA, MaterialBatchA, MIOItemw, Pack
from django.db import transaction
from django.db.models import F, Sum, DecimalField
from server.settings import get_sysconfig
from apps.wpmw.models import Wpr
from decimal import Decimal
@ -98,7 +97,6 @@ class MIOListSerializer(CustomModelSerializer):
order_number = serializers.CharField(source='order.number', read_only=True)
pu_order_number = serializers.CharField(
source='pu_order.number', read_only=True)
total_price = serializers.SerializerMethodField(label='总价格')
class Meta:
model = MIO
@ -106,12 +104,6 @@ class MIOListSerializer(CustomModelSerializer):
read_only_fields = EXCLUDE_FIELDS + \
['state', 'submit_time', 'submit_user', 'number']
def get_total_price(self, obj):
res = MIOItem.objects.filter(mio=obj, unit_price__isnull=False).aggregate(
total=Sum(F('count') * F('unit_price'),
output_field=DecimalField(max_digits=18, decimal_places=4)))
return res['total']
class MIOItemACreateSerializer(CustomModelSerializer):
class Meta:

View File

@ -1,4 +1,4 @@
# Generated by Django 4.2.27 on 2026-04-24 06:50
# Generated by Django 3.2.12 on 2026-03-12 03:26
from django.conf import settings
from django.db import migrations, models
@ -12,31 +12,11 @@ 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),
('wf', '0006_auto_20251215_1645'),
]
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(
name='PurchaseRequisition',
fields=[
@ -49,100 +29,10 @@ class Migration(migrations.Migration):
('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='%(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='创建人')),
('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='%(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='关联入库单')),
('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,
@ -171,25 +61,4 @@ class Migration(migrations.Migration):
'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,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

@ -1,27 +1,9 @@
from django.db import models
from apps.utils.models import BaseModel, CommonBDModel, CommonBModel
from apps.utils.models import BaseModel, CommonBDModel
from datetime import datetime
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):
today_str = datetime.now().strftime('%Y%m%d')
prefix = model_cls.PREFIX
@ -95,7 +77,7 @@ class WarehouseEntry(CommonBDModel):
number = models.CharField('编号', max_length=20, unique=True)
warehouse = models.ForeignKey(
WareHouse, verbose_name='',
'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')
@ -141,7 +123,7 @@ class WarehouseStock(BaseModel):
)
warehouse = models.ForeignKey(
WareHouse, verbose_name='',
'inm.WareHouse', verbose_name='',
on_delete=models.CASCADE, related_name='mpr_stocks')
entry = models.ForeignKey(
WarehouseEntry, verbose_name='来源入库单',

View File

@ -7,20 +7,10 @@ from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem,
WareHouse,
)
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):

View File

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

View File

@ -8,10 +8,8 @@ from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem,
WareHouse,
)
from apps.mpr.serializers import (
WareHouseSerializer,
PurchaseRequisitionListSerializer,
PurchaseRequisitionDetailSerializer,
PurchaseRequisitionCreateSerializer,
@ -32,20 +30,6 @@ 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):
"""
物资申购单

View File

@ -102,16 +102,16 @@ class MaterialViewSet(CustomModelViewSet):
data = [list(getter(item)) for item in odata]
return Response({'path': export_excel(field_data, data, '物料清单')})
class ShiftViewSet(CustomModelViewSet):
class ShiftViewSet(ListModelMixin, CustomGenericViewSet):
"""
list:班次
班次
"""
perms_map = {'get': '*'}
queryset = Shift.objects.all()
serializer_class = ShiftSerializer
search_fields = ['name', 'rule']
filterset_fields = ['rule']
search_fields = ['name']
ordering = ['rule', 'sort', 'id']

View File

@ -1,164 +0,0 @@
# 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,7 +1,4 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel, CommonADModel
from apps.mtm.models import Material
from apps.wf.models import Ticket
@ -74,9 +71,6 @@ class PuOrder(CommonBModel):
number = models.CharField('订单编号', max_length=20, null=True, blank=True)
supplier = models.ForeignKey(
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)
submit_time = models.DateTimeField('提交时间', null=True, blank=True)
submit_user = models.ForeignKey(
@ -132,151 +126,3 @@ class QuotationApply(CommonADModel):
apply_date = models.DateField(verbose_name="申请日期",auto_now_add=True)
ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单',
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,11 +1,9 @@
from decimal import Decimal
from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer
from apps.utils.constants import EXCLUDE_FIELDS_DEPT, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS
from rest_framework.exceptions import ValidationError, ParseError
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply
from apps.mtm.serializers import MaterialSerializer, MaterialSimpleSerializer
from django.db import transaction
from .services import PumService
@ -101,14 +99,6 @@ class PuOrderSerializer(CustomModelSerializer):
fields = '__all__'
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):
validated_data.pop('supplier')
if instance.state != PuOrder.PUORDER_CREATE:
@ -175,39 +165,3 @@ class QuotationApplySerializer(CustomModelSerializer):
model = QuotationApply
fields = "__all__"
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,178 +1,3 @@
from decimal import Decimal
from django.test import TestCase
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)
# Create your tests here.

View File

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet, PuContractViewSet, PuContractRecordViewSet)
from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet)
# from apps.pum.views import SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet
API_BASE_URL = 'api/pum/'
@ -11,8 +11,6 @@ router.register('supplier', SupplierViewSet, basename='supplier')
router.register('supplieraudit', SupplierAuditViewSet, basename='supplieraudit')
router.register('pu_plan', PuPlanViewSet, basename='pu_plan')
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_orderitem', PuOrderItemViewSet, basename='pu_orderitem')
router.register('quotation', QuotationApplyViewSet, basename='quotation')

View File

@ -1,9 +1,8 @@
from django.shortcuts import render
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet
from apps.pum.serializers import (SupplierSerializer, PuPlanSerializer, PuPlanItemSerializer, QuotationApplySerializer,
PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer,
PuContractSerializer, PuContractRecordSerializer)
PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer)
from rest_framework.exceptions import ParseError, PermissionDenied
from rest_framework.decorators import action
from rest_framework import serializers
@ -224,50 +223,3 @@ class QuotationApplyViewSet(TicketMixin, CustomModelViewSet):
search_fields = ['product_name', 'customer_name','contact_person']
ordering = ['create_time']
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

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

View File

@ -1,122 +0,0 @@
# 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,7 +1,4 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel
from apps.mtm.models import Material
@ -27,43 +24,16 @@ class Customer(CommonBModel):
return self.name
class Contract(CommonBDModel):
class Contract(CommonBModel):
"""
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)
number = models.CharField('合同编号', max_length=100, unique=True)
amount = models.IntegerField('合同金额', default=0)
customer = models.ForeignKey(Customer, verbose_name='关联客户',
on_delete=models.CASCADE, related_name='contract_customer')
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)
class Meta:
@ -73,53 +43,6 @@ class Contract(CommonBDModel):
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):
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):
"""
@ -164,58 +87,3 @@ class OrderItem(BaseModel):
delivered_count = models.PositiveIntegerField('已交货数量', default=0)
utask = models.ForeignKey('pm.utask', verbose_name='关联生产大任务',
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,8 +1,6 @@
from decimal import Decimal
from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer
from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.sam.models import Customer, Contract, Order, OrderItem
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE
from rest_framework.exceptions import ValidationError
from apps.mtm.serializers import MaterialSerializer
@ -83,29 +81,3 @@ class OrderItemSerializer(CustomModelSerializer):
validated_data.pop('product', None)
validated_data.pop('order', None)
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,178 +1,3 @@
from decimal import Decimal
from django.test import TestCase
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)
# Create your tests here.

View File

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

View File

@ -1,7 +1,7 @@
from django.shortcuts import render
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet
from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer, ContractRecordSerializer
from apps.sam.models import Customer, Contract, Order, OrderItem
from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin, CreateModelMixin, DestroyModelMixin
from apps.utils.mixins import BulkCreateModelMixin
@ -46,7 +46,6 @@ class ContractViewSet(CustomModelViewSet):
def perform_destroy(self, instance):
if Order.objects.filter(contract=instance).exists():
raise ParseError('该合同存在订单不可删除')
instance.delete()
class OrderViewSet(CustomModelViewSet):
@ -107,32 +106,3 @@ class OrderItemViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, Cust
if instance.order.state != Order.ORDER_CREATE:
raise ParseError('该订单状态下不可删除')
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

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

@ -510,7 +510,7 @@ class Mlogb(BaseModel):
@property
def mlogbdefect(self):
return self.mlogbdefect_set.all()
return MlogbDefect.objects.filter(mlogb=self)
def cal_count_pn_jgqbl(self, cal_mlog=False):
mqs = MlogbDefect.get_defect_qs_from_mlogb(self, ftype="in")
@ -535,58 +535,12 @@ class Mlogb(BaseModel):
if mlog and cal_mlog:
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.mlog_id and self.mlog and self.mlog.is_fix:
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)
had_inherited = inherited_qs.exists()
if MlogbDefect.objects.filter(mlogb=self, is_inherited=False).exists() or self.has_legacy_defect_count():
inherited_qs.delete()
if had_inherited and cal_count:
self.cal_count_notok(cal_mlog=False)
return
defect = self.get_default_inherited_defect()
if defect is None:
inherited_qs.delete()
if had_inherited and cal_count:
self.cal_count_notok(cal_mlog=False)
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):
"""TN: 生成记录的缺陷记录"""
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)
count = 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
def get_defect_qs(cls, ftype="all"):

View File

@ -1,6 +1,6 @@
from apps.wpm.models import BatchSt
import logging
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect, MlogUser
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect
from apps.mtm.models import Mgroup
import decimal
from django.db.models import Sum
@ -27,7 +27,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
mgroup_name = mgroup.name
mlogb1_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False,
material_out__isnull=False, mlog__mgroup=mgroup,
mlog__is_fix=False, batch=batch, need_inout=True).order_by("mlog__submit_time")
mlog__is_fix=False, batch=batch, need_inout=True)
if mlogb1_qs.exists():
data[f"{mgroup_name}_日期"] = []
data[f"{mgroup_name}_操作人"] = []
@ -38,7 +38,6 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_ok_full"] = 0
data[f"{mgroup_name}_count_pn_jgqbl"] = 0
mlogb_q_ids = []
cal_mlog = []
for item in mlogb1_qs:
# 找到对应的输入
mlogb_from:Mlogb = item.mlogb_from
@ -52,13 +51,6 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_pn_jgqbl"] += 0
if 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:
data[f"{mgroup_name}_日期"].append(item.mlog.handle_date)
data[f"{mgroup_name}_count_real"] += item.count_real
@ -68,12 +60,12 @@ def main(batch: str, mgroup_obj:Mgroup=None):
try:
data[f"{mgroup_name}_完全合格率"] = round((data[f"{mgroup_name}_count_ok_full"] / data[f"{mgroup_name}_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data[f"{mgroup_name}_完全合格率"] = 0
try:
data[f"{mgroup_name}_合格率"] = round((data[f"{mgroup_name}_count_ok"] / data[f"{mgroup_name}_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data[f"{mgroup_name}_合格率"] = 0
mlogbd1_qs = MlogbDefect.objects.filter(mlogb__in=mlogb1_qs, count__gt=0).values("defect__name").annotate(total=Sum("count"))
@ -93,8 +85,8 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_含缺陷_{item['defect__name']}_比例"] = round((item["total"] / data[f"{mgroup_name}_count_real"])*100, 2)
data[f"{mgroup_name}_日期"] = list(set(data[f"{mgroup_name}_日期"]))
data[f"{mgroup_name}_小日期"] = min(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_大日期"] = max(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_小日期"] = max(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_大日期"] = min(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data[f"{mgroup_name}_日期"]])
data[f"{mgroup_name}_操作人"] = list(set(data[f"{mgroup_name}_操作人"]))
data[f"{mgroup_name}_操作人"] = ";".join([item.name for item in data[f"{mgroup_name}_操作人"]])

View File

@ -1,216 +0,0 @@
# batch_gxerp.py 批次统计计算规则
`apps/wpm/scripts/batch_gxerp.py` 用于按 **批次号 (batch)** 汇总该批次在各工序组下的生产/检验数据,写入 `BatchSt.data` (JSON),同时维护 `first_time` / `last_time`
---
## 一、入参与前置条件
| 参数 | 说明 |
| --- | --- |
| `batch` | 批次号 |
| `mgroup_obj` | 工序组对象(当前实现未直接使用,仅作为入口签名) |
- 必须存在 `BatchSt.objects.get(batch=batch, version=1)`,否则报错并退出。
- 遍历 `Mgroup.objects.all().order_by("sort")`,对每个工序组分别统计。
- 仅统计已提交且已出库的报工单:`mlog.submit_time__isnull=False` 且 `material_out__isnull=False`,并要求 `need_inout=True`
---
## 二、字段命名约定
输出 JSON `data` 的 key 形式如下(`<G>` 代表工序组名):
| Key 模式 | 含义 |
| --- | --- |
| `批次号` | 批次号 |
| `<G>_日期` | 该工序组所有报工日期,去重后 `;` 拼接 |
| `<G>_小日期` / `<G>_大日期` | 该工序组最早 / 最晚日期(`YYYY-MM-DD` |
| `<G>_操作人` | 操作人姓名去重后 `;` 拼接 |
| `<G>_班次` | 班次名去重后 `;` 拼接 |
| `<G>_count_use` | 领用数(来自上游 `mlogb_from` |
| `<G>_count_real` | 实际生产数 |
| `<G>_count_ok` | 合格数 |
| `<G>_count_notok` | 不合格数 |
| `<G>_count_ok_full` | 完全合格数 |
| `<G>_count_pn_jgqbl` | 加工前不良数(来自上游 `mlogb_from` |
| `<G>_合格率` | 合格率(% |
| `<G>_完全合格率` | 完全合格率(% |
| `<G>_缺陷_<缺陷名>` | 该工序组的缺陷数量(按缺陷名汇总) |
| `<G>_缺陷_<缺陷名>_比例` | 缺陷数 / `count_real` × 100单位 % |
| `<G>_加工前_缺陷_<缺陷名>` | 上游 `mlogb_from` 上的缺陷数量 |
| `<G>_加工前_缺陷_<缺陷名>_比例` | 加工前缺陷数 / `count_use` × 100单位 % |
---
## 三、主流程:按工序组汇总(普通报工,`is_fix=False`
数据源 `mlogb1_qs`
```
Mlogb where mlog.submit_time IS NOT NULL
and material_out IS NOT NULL
and mlog.mgroup = <G>
and mlog.is_fix = False
and batch = <batch>
and need_inout = True
```
### 1. 累加规则
对每条 `Mlogb`
| 字段 | 累加来源 |
| --- | --- |
| `count_use` | `mlogb_from.count_use`(上游报工子项;若无 `mlogb_from` 则跳过) |
| `count_pn_jgqbl` | `mlogb_from.count_pn_jgqbl` |
| `count_real` | 当前 `item.count_real` |
| `count_ok` | 当前 `item.count_ok` |
| `count_ok_full` | 当前 `item.count_ok_full or 0` |
| `count_notok` | 当前 `item.count_notok or 0` |
| `操作人` | `mlog.handle_user`(最终去重,按 `name` 拼接) |
| `日期` | `mlog.handle_date`(最终去重排序) |
| `班次` | `mlog.shift.name` |
并记录所有上游 `mlogb_from.id``mlogb_q_ids`,用于"加工前缺陷"统计。
### 2. 合格率计算
```
完全合格率 = round(count_ok_full / count_real * 100, 2)
合格率 = round(count_ok / count_real * 100, 2)
```
`decimal.InvalidOperation` 异常时(如分母为 0`0`
### 3. 缺陷统计
- 当前工序缺陷:`MlogbDefect.filter(mlogb__in=mlogb1_qs, count__gt=0).values('defect__name').annotate(total=Sum('count'))`
- `<G>_缺陷_<name>` = total
- `<G>_缺陷_<name>_比例` = `round(total / count_real * 100, 2)`
- 加工前(上游)缺陷:`MlogbDefect.filter(mlogb__id__in=mlogb_q_ids, count__gt=0)...`
- `<G>_加工前_缺陷_<name>` = total
- `<G>_加工前_缺陷_<name>_比例` = `round(total / count_use * 100, 2)`
### 4. 日期字段
```python
data[f"{G}_小日期"] = min(日期列表).strftime("%Y-%m-%d") # 最早日期
data[f"{G}_大日期"] = max(日期列表).strftime("%Y-%m-%d") # 最晚日期
```
最终 `<G>_日期` 被覆写为去重排序后的字符串(`;` 拼接)。
---
## 四、外观检验返修(`is_fix=True`
数据源 `mlogb2_qs`
```
Mlogb where mlog.submit_time IS NOT NULL
and material_out IS NOT NULL
and mlog.mgroup.name = '外观检验'
and mlog.is_fix = True
and batch = <batch>
and need_inout = True
```
输出字段:
| Key | 计算 |
| --- | --- |
| `外观检验_返修_日期` | 报工日期,去重 `;` 拼接 |
| `外观检验_返修_操作人` | 操作人姓名,去重 `;` 拼接 |
| `外观检验_返修_count_real` | Σ `count_real` |
| `外观检验_返修_count_ok` | Σ `count_ok` |
| `外观检验_返修_count_ok_full` | Σ `count_ok_full or 0` |
| `外观检验_返修_缺陷_<name>` | `MlogbDefect` 按缺陷名汇总 |
| `外观检验_返修_缺陷_<name>_比例` | `round(total / 外观检验_返修_count_real * 100, 2)` |
---
## 五、外观检验车间库存抽检
数据源 `ft_qs`
```
FtestWork where type2 = TYPE2_SOME
and wm.mgroup.name = '外观检验'
and batch = <batch>
and submit_time IS NOT NULL
```
输出字段:
| Key | 计算 |
| --- | --- |
| `外观检验_车间库存抽检_日期` | `test_date` 去重 `;` 拼接 |
| `外观检验_车间库存抽检_操作人` | `test_user.name` 去重 `;` 拼接 |
| `外观检验_车间库存抽检_count_notok` | Σ `count_notok or 0` |
| `外观检验_车间库存抽检_缺陷_<name>` | `FtestworkDefect` 按缺陷名汇总(无比例字段) |
---
## 六、外观检验汇总指标(仅当存在 `外观检验_count_ok` 时计算)
| Key | 公式 |
| --- | --- |
| `外观检验_总合格数` | `外观检验_count_ok + 外观检验_返修_count_ok默认0` |
| `外观检验_总合格率` | `round(外观检验_总合格数 / 外观检验_count_real * 100, 2)` |
| `外观检验_完全总合格数` | `外观检验_count_ok_full + 外观检验_返修_count_ok_full默认0` |
| `外观检验_完全总合格率` | `round(外观检验_完全总合格数 / 外观检验_count_real * 100, 2)` |
| `外观检验_直通合格数` | `外观检验_总合格数 - 外观检验_车间库存抽检_count_notok默认0` |
### 直通合格率(依赖尺寸检验)
仅当 `尺寸检验_合格率` 存在时:
```
外观检验_直通合格率 = round(外观检验_总合格率 * 尺寸检验_合格率 / 100, 2)
外观检验_直通合格率2 = round(外观检验_直通合格数 / 尺寸检验_count_use * 100, 2)
```
仅当 `尺寸检验_完全合格率` 存在时:
```
外观检验_完全直通合格率 = round(外观检验_完全总合格率 * 尺寸检验_完全合格率 / 100, 2)
```
异常(`InvalidOperation` / `ZeroDivisionError`)回退为 `0`
---
## 七、写回 BatchSt
1. `data``MyJSONEncoder` 序列化后回填 `batchst.data`
2. 调 `get_f_l_date(data)`:扫描所有以 `_日期` 结尾的字段,按 `;` 拆开后逐个 `datetime.strptime("%Y-%m-%d")` 解析为 `date` 对象,再取最小/最大并转成上海时区的 `00:00:00` / `23:59:59`,得到 `first_time` / `last_time`。无法解析的片段会写日志并跳过。
3. 仅当现有 `first_time` 为空或新值更早时更新;`last_time` 反之。
4. 调用 `batchst.save()` 持久化。
---
## 八、关键依赖与字段含义速查
| 模型字段 | 中文释义 | 来源 |
| --- | --- | --- |
| `Mlogb.count_use` | 领用数 | 报工子项(来自 `mlogb_from` |
| `Mlogb.count_real` | 实际生产数 | 报工子项 |
| `Mlogb.count_ok` | 合格数 | `count_real - count_notok` |
| `Mlogb.count_ok_full` | 完全合格数 | `count_real - count_notok_full` |
| `Mlogb.count_notok` | 不合格数 | 缺陷 `okcate=30` 求和 |
| `Mlogb.count_pn_jgqbl` | 加工前不良 | `MlogbDefect` 中加工前不良求和 |
| `Mlogb.need_inout` | 是否需出入库 | 仅 `True` 参与统计 |
| `Mlogb.mlogb_from` | 上游报工子项 | 用于追溯领用与加工前缺陷 |
---
## 九、已知潜在问题
1. **`mgroup_obj` 入参未使用**:当前实现忽略该参数,对所有 `Mgroup` 全量遍历。
## 十、修订记录
- 修复 `小日期 / 大日期` 命名与含义反向,`小日期` 取最早日期、`大日期` 取最晚日期;同步修正 `batch_bxerp.py`、`batch_gzerp.py` 中所有同类字段。
- `get_f_l_date` 改为先 `datetime.strptime` 解析为 `date` 对象再比较,移除对字符串字典序的隐式依赖;非法日期片段记录日志后跳过。
- 所有合格率/直通率计算的兜底 `except` 同时捕获 `decimal.InvalidOperation``ZeroDivisionError`,以兼容 `Decimal` 与原生数值的除零场景;`batch_bxerp.py` 与 `batch_gzerp.py` 中仅捕获 `decimal.InvalidOperation` 的合格率分支也同步扩展。

View File

@ -57,12 +57,12 @@ def main(batch: str, mgroup_obj):
try:
data[f"{mgroup_name}_完全合格率"] = round((data[f"{mgroup_name}_count_ok_full"] / data[f"{mgroup_name}_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data[f"{mgroup_name}_完全合格率"] = 0
try:
data[f"{mgroup_name}_合格率"] = round((data[f"{mgroup_name}_count_ok"] / data[f"{mgroup_name}_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data[f"{mgroup_name}_合格率"] = 0
mlogbd1_qs = MlogbDefect.objects.filter(mlogb__in=mlogb1_qs, count__gt=0).values("defect__name").annotate(total=Sum("count"))
@ -78,8 +78,8 @@ def main(batch: str, mgroup_obj):
data[f"{mgroup_name}_日期"] = list(set(data[f"{mgroup_name}_日期"]))
data[f"{mgroup_name}_日期"].sort()
data[f"{mgroup_name}_小日期"] = min(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_大日期"] = max(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_小日期"] = max(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_大日期"] = min(data[f"{mgroup_name}_日期"]).strftime("%Y-%m-%d")
data[f"{mgroup_name}_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data[f"{mgroup_name}_日期"]])
data[f"{mgroup_name}_操作人"] = list(set(data[f"{mgroup_name}_操作人"]))
data[f"{mgroup_name}_操作人"] = ";".join([item.name for item in data[f"{mgroup_name}_操作人"]])
@ -144,20 +144,20 @@ def main(batch: str, mgroup_obj):
data["外观检验_总合格数"] = data["外观检验_count_ok"] + data.get("外观检验_返修_count_ok", 0)
try:
data["外观检验_总合格率"] = round((data["外观检验_总合格数"] / data["外观检验_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data["外观检验_总合格率"] = 0
data["外观检验_完全总合格数"] = data["外观检验_count_ok_full"] + data.get("外观检验_返修_count_ok_full", 0)
try:
data["外观检验_完全总合格率"] = round((data["外观检验_完全总合格数"] / data["外观检验_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data["外观检验_完全总合格率"] = 0
data["外观检验_直通合格数"] = data["外观检验_总合格数"] - data.get("外观检验_车间库存抽检_count_notok", 0)
if "尺寸检验_合格率" in data:
try:
data["外观检验_直通合格率"] = round((data["外观检验_总合格率"]* data["尺寸检验_合格率"])/100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data["外观检验_直通合格率"] = 0
try:
@ -168,7 +168,7 @@ def main(batch: str, mgroup_obj):
if "尺寸检验_完全合格率" in data:
try:
data["外观检验_完全直通合格率"] = round((data["外观检验_完全总合格率"]* data["尺寸检验_完全合格率"])/100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
data["外观检验_完全直通合格率"] = 0
res = get_f_l_date(data)

View File

@ -59,8 +59,8 @@ def main(batch: str, mgroup_obj=None):
data["棒料成型_切料人"] = ";".join([item.name for item in data["棒料成型_切料人"]])
data["棒料成型_日期"] = list(set(data["棒料成型_日期"]))
data["棒料成型_日期"].sort()
data["棒料成型_小日期"] = min(data["棒料成型_日期"]).strftime("%Y-%m-%d")
data["棒料成型_大日期"] = max(data["棒料成型_日期"]).strftime("%Y-%m-%d")
data["棒料成型_小日期"] = max(data["棒料成型_日期"]).strftime("%Y-%m-%d")
data["棒料成型_大日期"] = min(data["棒料成型_日期"]).strftime("%Y-%m-%d")
data["棒料成型_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["棒料成型_日期"]])
try:
data["棒料成型_合格率"] = round((data["棒料成型_count_ok"] * 100/ data["棒料成型_count_real"]), 1)
@ -95,8 +95,8 @@ def main(batch: str, mgroup_obj=None):
data["管料成型_合格率"] = round((data["管料成型_count_ok"] * 100 / data["管料成型_count_real"]), 1)
data["管料成型_日期"] = list(set(data["管料成型_日期"]))
data["管料成型_日期"].sort()
data["管料成型_小日期"] = min(data["管料成型_日期"]).strftime("%Y-%m-%d")
data["管料成型_大日期"] = max(data["管料成型_日期"]).strftime("%Y-%m-%d")
data["管料成型_小日期"] = max(data["管料成型_日期"]).strftime("%Y-%m-%d")
data["管料成型_大日期"] = min(data["管料成型_日期"]).strftime("%Y-%m-%d")
data["管料成型_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["管料成型_日期"]])
# 7车间生产入库数据/ 8车间中检数据
@ -127,8 +127,8 @@ def main(batch: str, mgroup_obj=None):
data["七车间入库_合格率"] = round((data["七车间入库_count"] - data["七车间入库_count_notok"]) * 100/ data["七车间入库_count"], 1)
data["七车间入库_日期"] = list(set(data["七车间入库_日期"]))
data["七车间入库_日期"].sort()
data["七车间入库_小日期"] = min(data["七车间入库_日期"]).strftime("%Y-%m-%d")
data["七车间入库_大日期"] = max(data["七车间入库_日期"]).strftime("%Y-%m-%d")
data["七车间入库_小日期"] = max(data["七车间入库_日期"]).strftime("%Y-%m-%d")
data["七车间入库_大日期"] = min(data["七车间入库_日期"]).strftime("%Y-%m-%d")
data["七车间入库_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["七车间入库_日期"]])
data["七车间入库_车间执行人"] = list(set(data["七车间入库_车间执行人"]))
data["七车间入库_车间执行人"] = ";".join([item.name for item in data["七车间入库_车间执行人"]])
@ -168,8 +168,8 @@ def main(batch: str, mgroup_obj=None):
data["十车间入库_仓库执行人"] = ";".join([item.name for item in data["十车间入库_仓库执行人"]])
data["十车间入库_日期"] = list(set(data["十车间入库_日期"]))
data["十车间入库_日期"].sort()
data["十车间入库_小日期"] = min(data["十车间入库_日期"]).strftime("%Y-%m-%d")
data["十车间入库_大日期"] = max(data["十车间入库_日期"]).strftime("%Y-%m-%d")
data["十车间入库_小日期"] = max(data["十车间入库_日期"]).strftime("%Y-%m-%d")
data["十车间入库_大日期"] = min(data["十车间入库_日期"]).strftime("%Y-%m-%d")
data["十车间入库_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["十车间入库_日期"]])
data["十车间入库_合格数"] = data["十车间入库_count"] - data["十车间入库_count_notok"]
data["十车间入库_合格率"] = round((data["十车间入库_count"] - data["十车间入库_count_notok"]) * 100/ data["十车间入库_count"], 1)
@ -222,8 +222,8 @@ def main(batch: str, mgroup_obj=None):
data[f'管料退火_{field}'] += getattr(item, field)
data["管料退火_日期"] = list(set(data["管料退火_日期"]))
data["管料退火_日期"].sort()
data["管料退火_小日期"] = min(data["管料退火_日期"]).strftime("%Y-%m-%d")
data["管料退火_大日期"] = max(data["管料退火_日期"]).strftime("%Y-%m-%d")
data["管料退火_小日期"] = max(data["管料退火_日期"]).strftime("%Y-%m-%d")
data["管料退火_大日期"] = min(data["管料退火_日期"]).strftime("%Y-%m-%d")
data["管料退火_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["管料退火_日期"]])
data["管料退火_操作人"] = list(set(data["管料退火_操作人"]))
data["管料退火_操作人"] = ";".join([item.name for item in data["管料退火_操作人"]])
@ -257,8 +257,8 @@ def main(batch: str, mgroup_obj=None):
data[f'六车间领料_{field}'] += getattr(item, field)
data["六车间领料_日期"] = list(set(data["六车间领料_日期"]))
data["六车间领料_日期"].sort()
data["六车间领料_小日期"] = min(data["六车间领料_日期"]).strftime("%Y-%m-%d")
data["六车间领料_大日期"] = max(data["六车间领料_日期"]).strftime("%Y-%m-%d")
data["六车间领料_小日期"] = max(data["六车间领料_日期"]).strftime("%Y-%m-%d")
data["六车间领料_大日期"] = min(data["六车间领料_日期"]).strftime("%Y-%m-%d")
data["六车间领料_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["六车间领料_日期"]])
data["六车间领料_仓库执行人"] = list(set(data["六车间领料_仓库执行人"]))
data["六车间领料_仓库执行人"] = ";".join([item.name for item in data["六车间领料_仓库执行人"]])
@ -283,8 +283,8 @@ def main(batch: str, mgroup_obj=None):
data["六车间交接领料_接料人"].append(item.recive_user)
data["六车间交接领料_日期"] = list(set(data["六车间交接领料_日期"]))
data["六车间交接领料_日期"].sort()
data["六车间交接领料_小日期"] = min(data["六车间交接领料_日期"]).strftime("%Y-%m-%d")
data["六车间交接领料_大日期"] = max(data["六车间交接领料_日期"]).strftime("%Y-%m-%d")
data["六车间交接领料_小日期"] = max(data["六车间交接领料_日期"]).strftime("%Y-%m-%d")
data["六车间交接领料_大日期"] = min(data["六车间交接领料_日期"]).strftime("%Y-%m-%d")
data["六车间交接领料_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["六车间交接领料_日期"]])
data["六车间交接领料_送料人"] = list(set(data["六车间交接领料_送料人"]))
data["六车间交接领料_送料人"] = ";".join([item.name for item in data["六车间交接领料_送料人"]])
@ -320,14 +320,14 @@ def main(batch: str, mgroup_obj=None):
data[f'六车间_{mgroup_name}_{field}'] += getattr(item, field)
data[f'六车间_{mgroup_name}_日期'] = list(set(data[f'六车间_{mgroup_name}_日期']))
data[f'六车间_{mgroup_name}_日期'].sort()
data[f'六车间_{mgroup_name}_小日期'] = min(data[f'六车间_{mgroup_name}_日期']).strftime("%Y-%m-%d")
data[f'六车间_{mgroup_name}_大日期'] = max(data[f'六车间_{mgroup_name}_日期']).strftime("%Y-%m-%d")
data[f'六车间_{mgroup_name}_小日期'] = max(data[f'六车间_{mgroup_name}_日期']).strftime("%Y-%m-%d")
data[f'六车间_{mgroup_name}_大日期'] = min(data[f'六车间_{mgroup_name}_日期']).strftime("%Y-%m-%d")
data[f'六车间_{mgroup_name}_日期'] = ";".join([item.strftime("%Y-%m-%d") for item in data[f'六车间_{mgroup_name}_日期']])
data[f'六车间_{mgroup_name}_操作人'] = list(set(data[f'六车间_{mgroup_name}_操作人']))
data[f'六车间_{mgroup_name}_操作人'] = ";".join([item.name for item in data[f'六车间_{mgroup_name}_操作人']])
try:
data[f'六车间_{mgroup_name}_合格率'] = round(data[f'六车间_{mgroup_name}_count_ok'] * 100/ data[f'六车间_{mgroup_name}_count_real'], 1)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
# myLogger.error(f"六车间_{mgroup_name}_合格率decimal.InvalidOperation-{data}")
data[f'六车间_{mgroup_name}_合格率'] = 0
@ -359,8 +359,8 @@ def main(batch: str, mgroup_obj=None):
data[f'六车间中检_{field}'] += getattr(item, field)
data["六车间中检_日期"] = list(set(data["六车间中检_日期"]))
data["六车间中检_日期"].sort()
data["六车间中检_小日期"] = min(data["六车间中检_日期"]).strftime("%Y-%m-%d")
data["六车间中检_大日期"] = max(data["六车间中检_日期"]).strftime("%Y-%m-%d")
data["六车间中检_小日期"] = max(data["六车间中检_日期"]).strftime("%Y-%m-%d")
data["六车间中检_大日期"] = min(data["六车间中检_日期"]).strftime("%Y-%m-%d")
data["六车间中检_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["六车间中检_日期"]])
data['六车间中检_检验人'] = list(set(data['六车间中检_检验人']))
data['六车间中检_检验人'] = ";".join([item.name for item in data['六车间中检_检验人']])
@ -395,7 +395,7 @@ def main(batch: str, mgroup_obj=None):
data['六车间生产入库_检验人'] = ";".join([item.name for item in data['六车间生产入库_检验人']])
try:
data['六车间生产入库_合格率'] = round((data['六车间生产入库_count'] - data['六车间生产入库_count_notok']) * 100/ data['六车间生产入库_count'], 1)
except (decimal.InvalidOperation, ZeroDivisionError):
except decimal.InvalidOperation:
# myLogger.error("六车间生产入库_合格率decimal.InvalidOperation-{data}")
data['六车间生产入库_合格率'] = 0
@ -419,8 +419,8 @@ def main(batch: str, mgroup_obj=None):
if "六车间生产入库_日期" in data:
data["六车间生产入库_日期"] = list(set(data["六车间生产入库_日期"]))
data["六车间生产入库_日期"].sort()
data["六车间生产入库_小日期"] = min(data["六车间生产入库_日期"]).strftime("%Y-%m-%d")
data["六车间生产入库_大日期"] = max(data["六车间生产入库_日期"]).strftime("%Y-%m-%d")
data["六车间生产入库_小日期"] = max(data["六车间生产入库_日期"]).strftime("%Y-%m-%d")
data["六车间生产入库_大日期"] = min(data["六车间生产入库_日期"]).strftime("%Y-%m-%d")
data["六车间生产入库_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["六车间生产入库_日期"]])
# 成品检验数据
@ -497,8 +497,8 @@ def main(batch: str, mgroup_obj=None):
data['销售发货_仓库执行人'] = ";".join([item.name for item in data['销售发货_仓库执行人']])
data["销售发货_日期"] = list(set(data["销售发货_日期"]))
data["销售发货_日期"].sort()
data["销售发货_小日期"] = min(data["销售发货_日期"]).strftime("%Y-%m-%d")
data["销售发货_大日期"] = max(data["销售发货_日期"]).strftime("%Y-%m-%d")
data["销售发货_小日期"] = max(data["销售发货_日期"]).strftime("%Y-%m-%d")
data["销售发货_大日期"] = min(data["销售发货_日期"]).strftime("%Y-%m-%d")
data["销售发货_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["销售发货_日期"]])
# if data.get("六车间领料_count", 0) > 0 or data.get("六车间交接领料_count", 0) > 0:

View File

@ -228,11 +228,10 @@ class MlogbDefectSerializer(CustomModelSerializer):
defect_okcate = serializers.CharField(source="defect.okcate", read_only=True)
class Meta:
model = MlogbDefect
fields = ["id", "defect_name", "count", "mlogb", "defect", "defect_okcate", "count_has", "is_inherited"]
fields = ["id", "defect_name", "count", "mlogb", "defect", "defect_okcate", "count_has"]
read_only_fields = EXCLUDE_FIELDS_BASE + ["mlogb"]
extra_kwargs = {
'count_has': {'required': False},
'is_inherited': {'required': False},
}
def validate(self, attrs):
@ -301,9 +300,6 @@ class MlogListSerializer(CustomModelSerializer):
handle_users_ = UserSimpleSerializer(
source='handle_users', many=True, read_only=True)
wm_in_defect = serializers.PrimaryKeyRelatedField(source='wm_in.defect', read_only=True)
wm_in_defect_name = serializers.CharField(source='wm_in.defect.name', read_only=True)
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
class Meta:
model = Mlog
@ -318,9 +314,6 @@ class MlogbDetailSerializer(CustomModelSerializer):
material_out_tracking = serializers.IntegerField(source="material_out.tracking", read_only=True)
mlogbdefect = MlogbDefectSerializer(many=True, read_only=True)
test_user_name = serializers.CharField(source='test_user.name', read_only=True)
wm_in_defect = serializers.PrimaryKeyRelatedField(source='wm_in.defect', read_only=True)
wm_in_defect_name = serializers.CharField(source='wm_in.defect.name', read_only=True)
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
class Meta:
model = Mlogb
@ -374,9 +367,6 @@ class MlogSerializer(CustomModelSerializer):
mlogdefect = MlogbDefectSerializer(many=True, required=False)
mlogindefect = MlogbDefectSerializer(many=True, label="前道不良", required=False)
wm_in_defect = serializers.PrimaryKeyRelatedField(source='wm_in.defect', read_only=True)
wm_in_defect_name = serializers.CharField(source='wm_in.defect.name', read_only=True)
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
class Meta:
model = Mlog
fields = '__all__'
@ -487,7 +477,6 @@ class MlogSerializer(CustomModelSerializer):
if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects)
mlogb.cal_count_notok(cal_mlog=False)
mlogb.sync_inherited_defect(cal_count=True)
instance.cal_mlog_count_from_mlogb()
return instance
@ -578,7 +567,7 @@ class MlogSerializer(CustomModelSerializer):
mox.save()
Mlogb.objects.filter(mlog=instance, material_out__isnull=False).exclude(id=mox.id).delete()
if need_mdefect:
MlogbDefect.objects.filter(mlogb__mlog=instance, is_inherited=False).delete()
MlogbDefect.objects.filter(mlogb__mlog=instance).delete()
mlogb_defect_objects = [
MlogbDefect(**{**item, "mlogb": mox, "id": idWorker.get_id()})
for item in mlogdefect if item["count"] > 0
@ -586,7 +575,6 @@ class MlogSerializer(CustomModelSerializer):
if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects)
mox.cal_count_notok(cal_mlog=False)
mox.sync_inherited_defect(cal_count=True)
instance.cal_mlog_count_from_mlogb()
return instance
@ -1123,8 +1111,6 @@ class MlogbOutUpdateSerializer(CustomModelSerializer):
if mlogb_defect_objects:
MlogbDefect.objects.bulk_create(mlogb_defect_objects)
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
def validate(self, attrs):

View File

@ -610,18 +610,6 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
mlog.submit_user = None
mlog.save()
# 兜底清理历史 is_fix 错误带入的继承缺陷
if is_fix:
legacy_mlogbouts = list(Mlogb.objects.filter(
mlog=mlog,
material_out__isnull=False,
mlogbdefect__is_inherited=True,
).distinct())
if legacy_mlogbouts:
for mlogbout in legacy_mlogbouts:
mlogbout.sync_inherited_defect(cal_count=True)
mlog.cal_mlog_count_from_mlogb()
# mtask变更状态
update_mtaskIds = []
if mlog.mtask:

View File

@ -65,25 +65,16 @@ def get_f_l_date(data):
if v:
if isinstance(v, list):
myLogger.error(f"get_f_l_date {k} {v}")
continue
dates = []
for s in v.split(";"):
s = s.strip()
if not s:
continue
try:
dates.append(datetime.strptime(s, "%Y-%m-%d").date())
except ValueError:
myLogger.error(f"get_f_l_date invalid date {k} {s}")
if not dates:
continue
cur_min = min(dates)
cur_max = max(dates)
if first_date is None or cur_min < first_date:
first_date = cur_min
if last_date is None or cur_max > last_date:
last_date = cur_max
return {"first_date": first_date.strftime("%Y-%m-%d") if first_date else None,
"last_date": last_date.strftime("%Y-%m-%d") if last_date else None,
"first_time": datetime.combine(first_date, datetime.min.time()).replace(tzinfo=tz_shanghai) if first_date else None,
"last_time": datetime.combine(last_date, datetime.max.time().replace(microsecond=0)).replace(tzinfo=tz_shanghai) if last_date else None}
v = v.split(";")
if first_date is None:
first_date = min(v)
else:
first_date = min(first_date, min(v))
if last_date is None:
last_date = max(v)
else:
last_date = max(last_date, max(v))
return {"first_date": first_date,
"last_date": last_date,
"first_time": datetime.strptime(f"{first_date} 00:00:00", "%Y-%m-%d %H:%M:%S").replace(tzinfo=tz_shanghai) if first_date else None,
"last_time": datetime.strptime(f"{last_date} 23:59:59", "%Y-%m-%d %H:%M:%S").replace(tzinfo=tz_shanghai) if last_date else None}

View File

@ -274,7 +274,6 @@ class MlogViewSet(CustomModelViewSet):
"material_out__process",
"mgroup__process",
"submit_user",
"wm_in__defect",
]
# select_related_fields = ['create_by', 'update_by', 'mtask', 'mtaskb', 'mgroup',
# 'handle_user', 'handle_user_2', 'equipment', 'mgroup__belong_dept',
@ -575,7 +574,7 @@ class HandoverViewSet(CustomModelViewSet):
serializer_class = HandoverSerializer
select_related_fields = ["send_user", "send_mgroup", "send_dept", "recive_user", "recive_mgroup", "recive_dept", "wm", "material_changed", "material"]
filterset_class = HandoverFilter
search_fields = ["material__name", "material__number", "material__specification", "batch", "material__model", "b_handover__batch", "new_batch", "wm__batch"]
search_fields = ["id", "material__name", "material__number", "material__specification", "batch", "material__model", "b_handover__batch", "new_batch", "wm__batch"]
prefetch_related_fields = [Prefetch("b_handover", queryset=Handoverb.objects.select_related("wm__defect"))]
def perform_destroy(self, instance: Handover):
@ -765,10 +764,7 @@ class MlogbViewSet(CustomListModelMixin, CustomGenericViewSet):
perms_map = {"get": "*"}
queryset = Mlogb.objects.all()
serializer_class = MlogbDetailSerializer
select_related_fields = ["material_out", "material_in", "test_user", "wm_in__defect"]
prefetch_related_fields = [
Prefetch("mlogbdefect_set", queryset=MlogbDefect.objects.select_related("defect")),
]
select_related_fields = ["material_out", "material_in", "test_user"]
filterset_class = MlogbFilter
ordering = ["create_time"]
@ -981,8 +977,6 @@ class MlogbInViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyMode
Mlogbw.objects.get_or_create(number=numberx, mlogb=mlogbout)
else:
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()
def perform_create(self, serializer):

View File

@ -1,19 +1,3 @@
## 3.1.2026042912
- feat: 新增功能
- mio 列表接口新增 total_price 返回明细金额合计 [caoqianming]
- handover移除search包含id [caoqianming]
- shift 视图改为完整 CRUD 以支持班次管理页 [caoqianming]
- enm 修改重跑能源计算 不用从mplogx 开始计算 [TianyangZhang]
- 光芯科技 主要修改采购功能 [TianyangZhang]
- add contract settlement workflows [caoqianming]
- batch_bxerp添加子工序操作人 [caoqianming]
- 修改 hrm & rpm 代码 [TianyangZhang]
- 修改光芯人员导入功能 [TianyangZhang]
- fix: 问题修复
- 批次统计修复小/大日期反向并增强除零兜底 [caoqianming]
- mlogdefect 空列表时仍同步继承缺陷 [caoqianming]
- 重建wpr [caoqianming]
## 3.1.2026033008
- feat: 新增功能
- WMaterialViewSet 添加手动来料创建和删除接口 [caoqianming]

View File

@ -35,7 +35,7 @@ sys.path.insert(0, os.path.join(BASE_DIR, 'apps'))
ALLOWED_HOSTS = ['*']
SYS_NAME = '星途工厂综合管理系统'
SYS_VERSION = '3.1.2026042912'
SYS_VERSION = '3.1.2026033008'
X_FRAME_OPTIONS = 'SAMEORIGIN'
# Application definition