Compare commits

...

12 Commits

Author SHA1 Message Date
caoqianming e2b1f266aa fix: mlogdefect 空列表时仍同步继承缺陷
前端传 mlogdefect: [] 时,原 is not None 判断会走进 need_mdfect 分支,
既不创建真实缺陷也跳过 sync_inherited_defect,导致 mlogb 无任何缺陷标识。
改为无论哪个分支都兜底调用 sync_inherited_defect,由其内部判断互斥。
2026-04-24 15:45:58 +08:00
TianyangZhang c2ee88d2bf Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-24 15:00:48 +08:00
TianyangZhang 7577a46900 feat: enm 修改重跑能源计算 不用从mplogx 开始计算 2026-04-24 15:00:47 +08:00
caoqianming f6d934bbb1 fix: 重建wpr 2026-04-24 14:52:01 +08:00
caoqianming b6b79da3b1 Inherit batch output defect markers 2026-04-23 16:32:40 +08:00
TianyangZhang 48305ed6fb Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-21 16:05:08 +08:00
TianyangZhang 949620809a feat:光芯科技 主要修改采购功能 2026-04-21 16:05:07 +08:00
caoqianming cafecd4d4a feat: add contract settlement workflows 2026-04-20 16:56:11 +08:00
caoqianming a111f493e1 Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-04-14 14:16:18 +08:00
caoqianming 3c931040cf feat: batch_bxerp添加子工序操作人 2026-04-14 14:16:17 +08:00
TianyangZhang 159b644126 feat: 修改 hrm & rpm 代码 2026-04-07 13:42:30 +08:00
TianyangZhang a82405e451 feat:修改光芯人员导入功能 2026-03-31 11:08:12 +08:00
34 changed files with 1563 additions and 235 deletions

View File

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

View File

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

View File

@ -14,6 +14,7 @@ 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 settings.DAHUA_ENABLED: # 如果大华没启用, 直接返回
if not getattr(settings, 'DAHUA_ENABLED', False): # 如果大华没启用, 直接返回
return
dh_id = ep.third_info.get('dh_id', None)
dh_photo = ep.third_info.get('dh_photo', None)

View File

@ -387,20 +387,39 @@ class EmployeeViewSet(CustomModelViewSet):
if not re.match(r'^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$', data['id_number']):
raise ParseError(f'{row_num}行,身份证号格式不正确')
# 查找或更新
# 查找或创建/补全
try:
with transaction.atomic():
obj, created = Employee.objects.update_or_create(
id_number=id_number,
name=name,
defaults=data
)
# 优先按身份证号匹配,匹配不到再按姓名匹配
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
except Exception as e:
raise
if created:
myLogger.info(f"✅ 第{row_num}行新增成功:{name}")
else:
myLogger.info(f"✅ 第{row_num}行更新成功:{name}")
success += 1

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,20 @@ 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,6 +1,7 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.mpr.views import (
WareHouseViewSet,
PurchaseRequisitionViewSet, PurchaseRequisitionItemViewSet,
WarehouseEntryViewSet, WarehouseEntryItemViewSet,
WarehouseStockViewSet,
@ -10,6 +11,7 @@ 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,8 +8,10 @@ from apps.mpr.models import (
PurchaseRequisition, PurchaseRequisitionItem,
WarehouseEntry, WarehouseEntryItem, WarehouseStock,
MaterialRequisition, MaterialRequisitionItem,
WareHouse,
)
from apps.mpr.serializers import (
WareHouseSerializer,
PurchaseRequisitionListSerializer,
PurchaseRequisitionDetailSerializer,
PurchaseRequisitionCreateSerializer,
@ -30,6 +32,20 @@ from apps.mpr.filters import (
)
class WareHouseViewSet(CustomModelViewSet):
"""
库房管理
"""
queryset = WareHouse.objects.all()
serializer_class = WareHouseSerializer
search_fields = ['number', 'name', 'place']
ordering = '-create_time'
perms_map = {
'get': '*', 'post': 'warehouse.create',
'put': 'warehouse.update', 'delete': 'warehouse.delete',
}
class PurchaseRequisitionViewSet(TicketMixin, CustomModelViewSet):
"""
物资申购单

View File

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

View File

@ -1,4 +1,7 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel, CommonADModel
from apps.mtm.models import Material
from apps.wf.models import Ticket
@ -71,6 +74,9 @@ 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(
@ -126,3 +132,151 @@ 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,9 +1,11 @@
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
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.mtm.serializers import MaterialSerializer, MaterialSimpleSerializer
from django.db import transaction
from .services import PumService
@ -99,6 +101,14 @@ 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:
@ -165,3 +175,39 @@ 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,3 +1,178 @@
from decimal import Decimal
from django.test import TestCase
# Create your tests here.
from apps.pum.models import Supplier
from apps.pum.serializers import PuOrderSerializer
from apps.pum.serializers import PuContractRecordSerializer
from rest_framework.exceptions import ParseError
class PuContractSettlementTests(TestCase):
def test_purchase_contract_record_updates_paid_summary(self):
supplier = Supplier.objects.create(name='供应商A')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同A',
number='PC-001',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.paid_amount, Decimal('800.00'))
self.assertEqual(contract.unpaid_amount, Decimal('1200.00'))
self.assertEqual(contract.pay_progress, Decimal('40.00'))
self.assertEqual(contract.status, contract.STATUS_ACTIVE)
def test_purchase_contract_record_delete_refreshes_summary_and_is_physical(self):
supplier = Supplier.objects.create(name='供应商C')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同C',
number='PC-003',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
record.delete()
contract.refresh_from_db()
self.assertEqual(contract.paid_amount, Decimal('0.00'))
self.assertEqual(contract.unpaid_amount, Decimal('2000.00'))
self.assertEqual(contract.pay_progress, Decimal('0.00'))
self.assertEqual(contract.status, contract.STATUS_DRAFT)
self.assertFalse(PuContractRecord._base_manager.filter(pk=record.pk).exists())
def test_purchase_contract_delete_is_physical(self):
supplier = Supplier.objects.create(name='供应商D')
from apps.pum.models import PuContract
contract = PuContract.objects.create(
name='采购合同D',
number='PC-004',
contract_amount=Decimal('500.00'),
supplier=supplier,
sign_date='2026-04-20',
)
contract.delete()
self.assertFalse(PuContract._base_manager.filter(pk=contract.pk).exists())
def test_purchase_contract_status_auto_transitions_by_records(self):
supplier = Supplier.objects.create(name='供应商E')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同E',
number='PC-005',
contract_amount=Decimal('2000.00'),
supplier=supplier,
sign_date='2026-04-20',
)
self.assertEqual(contract.status, PuContract.STATUS_DRAFT)
first_record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('800.00'),
stage_type=PuContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_ACTIVE)
PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('1200.00'),
stage_type=PuContractRecord.STAGE_FINAL,
)
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_DONE)
first_record.delete()
contract.refresh_from_db()
self.assertEqual(contract.status, PuContract.STATUS_ACTIVE)
def test_purchase_terminated_contract_forbids_record_changes(self):
supplier = Supplier.objects.create(name='供应商F')
from apps.pum.models import PuContract, PuContractRecord
contract = PuContract.objects.create(
name='采购合同F',
number='PC-006',
contract_amount=Decimal('1000.00'),
supplier=supplier,
sign_date='2026-04-20',
status=PuContract.STATUS_TERMINATED,
)
serializer = PuContractRecordSerializer(data={
'contract': contract.id,
'record_date': '2026-04-21',
'amount': '100.00',
'stage_type': 10,
'pay_method': 10,
})
self.assertFalse(serializer.is_valid())
self.assertIn('合同已终止,不可操作付款流水', str(serializer.errors))
contract.status = PuContract.STATUS_DRAFT
contract.save(refresh_settlement=False)
record = PuContractRecord.objects.create(
contract=contract,
record_date='2026-04-20',
amount=Decimal('100.00'),
stage_type=PuContractRecord.STAGE_OTHER,
)
contract.status = PuContract.STATUS_TERMINATED
contract.save(refresh_settlement=False)
with self.assertRaises(ParseError):
from apps.pum.views import PuContractRecordViewSet
viewset = PuContractRecordViewSet()
viewset.request = None
viewset.perform_destroy(record)
def test_purchase_order_serializer_accepts_purchase_contract(self):
supplier = Supplier.objects.create(name='供应商B')
from apps.pum.models import PuContract
contract = PuContract.objects.create(
name='采购合同B',
number='PC-002',
contract_amount=Decimal('500.00'),
supplier=supplier,
sign_date='2026-04-20',
)
serializer = PuOrderSerializer(data={'supplier': supplier.id, 'contract': contract.id})
self.assertTrue(serializer.is_valid(), serializer.errors)

View File

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet)
from apps.pum.views import (SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet, SupplierAuditViewSet, QuotationApplyViewSet, PuContractViewSet, PuContractRecordViewSet)
# from apps.pum.views import SupplierViewSet, PuPlanViewSet, PuPlanItemViewSet, PuOrderViewSet, PuOrderItemViewSet
API_BASE_URL = 'api/pum/'
@ -11,6 +11,8 @@ 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,8 +1,9 @@
from django.shortcuts import render
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply
from apps.pum.models import Supplier, PuPlan, PuPlanItem, PuOrder, PuOrderItem, SupplierAudit, QuotationApply, PuContract, PuContractRecord
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet
from apps.pum.serializers import (SupplierSerializer, PuPlanSerializer, PuPlanItemSerializer, QuotationApplySerializer,
PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer)
PuOrderSerializer, PuOrderItemSerializer, AddSerializer, SupplierAuditSerializer,
PuContractSerializer, PuContractRecordSerializer)
from rest_framework.exceptions import ParseError, PermissionDenied
from rest_framework.decorators import action
from rest_framework import serializers
@ -223,3 +224,50 @@ 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 settings.DAHUA_ENABLED:
if getattr(settings, 'DAHUA_ENABLED', False):
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 settings.DAHUA_ENABLED:
if getattr(settings, 'DAHUA_ENABLED', False):
dhClient.request(**dhapis['person_img_upload'], file_path_rela=validated_data['photo'])
return super().update(instance, validated_data)

View File

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

View File

@ -1,4 +1,7 @@
from decimal import Decimal
from django.db import models
from django.db.models import Sum
from apps.utils.models import CommonBModel, BaseModel, CommonBDModel
from apps.mtm.models import Material
@ -24,16 +27,43 @@ class Customer(CommonBModel):
return self.name
class Contract(CommonBModel):
class Contract(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_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:
@ -43,6 +73,53 @@ class Contract(CommonBModel):
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):
"""
@ -87,3 +164,58 @@ 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,6 +1,8 @@
from decimal import Decimal
from rest_framework import serializers
from apps.utils.serializers import CustomModelSerializer
from apps.sam.models import Customer, Contract, Order, OrderItem
from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE
from rest_framework.exceptions import ValidationError
from apps.mtm.serializers import MaterialSerializer
@ -81,3 +83,29 @@ 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,3 +1,178 @@
from decimal import Decimal
from django.test import TestCase
# Create your tests here.
from apps.sam.models import Contract, Customer
from apps.sam.serializers import ContractRecordSerializer
from rest_framework.exceptions import ParseError
class ContractSettlementTests(TestCase):
def test_sales_contract_record_updates_received_summary(self):
customer = Customer.objects.create(
name='客户A',
contact='张三',
contact_phone='13800000001',
)
contract = Contract.objects.create(
name='销售合同A',
number='SC-001',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('200.00'),
stage_type=ContractRecord.STAGE_MIDDLE,
)
contract.refresh_from_db()
self.assertEqual(contract.received_amount, Decimal('500.00'))
self.assertEqual(contract.unreceived_amount, Decimal('500.00'))
self.assertEqual(contract.receive_progress, Decimal('50.00'))
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
def test_sales_contract_record_delete_refreshes_summary_and_is_physical(self):
customer = Customer.objects.create(
name='客户B',
contact='李四',
contact_phone='13800000002',
)
contract = Contract.objects.create(
name='销售合同B',
number='SC-002',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
record.delete()
contract.refresh_from_db()
self.assertEqual(contract.received_amount, Decimal('0.00'))
self.assertEqual(contract.unreceived_amount, Decimal('1000.00'))
self.assertEqual(contract.receive_progress, Decimal('0.00'))
self.assertEqual(contract.status, Contract.STATUS_DRAFT)
self.assertFalse(ContractRecord._base_manager.filter(pk=record.pk).exists())
def test_sales_contract_delete_is_physical(self):
customer = Customer.objects.create(
name='客户C',
contact='王五',
contact_phone='13800000003',
)
contract = Contract.objects.create(
name='销售合同C',
number='SC-003',
amount=500,
customer=customer,
sign_date='2026-04-20',
)
contract.delete()
self.assertFalse(Contract._base_manager.filter(pk=contract.pk).exists())
def test_sales_contract_status_auto_transitions_by_records(self):
customer = Customer.objects.create(
name='客户D',
contact='赵六',
contact_phone='13800000004',
)
contract = Contract.objects.create(
name='销售合同D',
number='SC-004',
amount=1000,
customer=customer,
sign_date='2026-04-20',
)
from apps.sam.models import ContractRecord
self.assertEqual(contract.status, Contract.STATUS_DRAFT)
first_record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-21',
amount=Decimal('300.00'),
stage_type=ContractRecord.STAGE_FIRST,
)
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
ContractRecord.objects.create(
contract=contract,
record_date='2026-04-22',
amount=Decimal('700.00'),
stage_type=ContractRecord.STAGE_FINAL,
)
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_DONE)
first_record.delete()
contract.refresh_from_db()
self.assertEqual(contract.status, Contract.STATUS_ACTIVE)
def test_sales_terminated_contract_forbids_record_changes(self):
customer = Customer.objects.create(
name='客户E',
contact='孙七',
contact_phone='13800000005',
)
contract = Contract.objects.create(
name='销售合同E',
number='SC-005',
amount=1000,
customer=customer,
sign_date='2026-04-20',
status=Contract.STATUS_TERMINATED,
)
serializer = ContractRecordSerializer(data={
'contract': contract.id,
'record_date': '2026-04-21',
'amount': '100.00',
'stage_type': 10,
'pay_method': 10,
})
self.assertFalse(serializer.is_valid())
self.assertIn('合同已终止,不可操作到款流水', str(serializer.errors))
contract.status = Contract.STATUS_DRAFT
contract.save(refresh_settlement=False)
from apps.sam.models import ContractRecord
record = ContractRecord.objects.create(
contract=contract,
record_date='2026-04-20',
amount=Decimal('100.00'),
stage_type=ContractRecord.STAGE_OTHER,
)
contract.status = Contract.STATUS_TERMINATED
contract.save(refresh_settlement=False)
with self.assertRaises(ParseError):
from apps.sam.views import ContractRecordViewSet
viewset = ContractRecordViewSet()
viewset.request = None
viewset.perform_destroy(record)

View File

@ -1,6 +1,6 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet)
from apps.sam.views import (CustomerViewSet, ContractViewSet, OrderViewSet, OrderItemViewSet, ContractRecordViewSet)
API_BASE_URL = 'api/sam/'
HTML_BASE_URL = 'dhtml/sam/'
@ -8,6 +8,7 @@ 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
from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer
from apps.sam.models import Customer, Contract, Order, OrderItem, ContractRecord
from apps.sam.serializers import CustomerSerializer, ContractSerializer, OrderSerializer, OrderItemSerializer, ContractRecordSerializer
from rest_framework.exceptions import ParseError
from rest_framework.mixins import ListModelMixin, CreateModelMixin, DestroyModelMixin
from apps.utils.mixins import BulkCreateModelMixin
@ -46,6 +46,7 @@ class ContractViewSet(CustomModelViewSet):
def perform_destroy(self, instance):
if Order.objects.filter(contract=instance).exists():
raise ParseError('该合同存在订单不可删除')
instance.delete()
class OrderViewSet(CustomModelViewSet):
@ -106,3 +107,32 @@ 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

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

View File

@ -535,12 +535,51 @@ 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.mlogb_from_id and self.mlogb_from and self.mlogb_from.wm_in_id:
return self.mlogb_from.wm_in.defect
if self.wm_in_id and self.wm_in:
return self.wm_in.defect
if self.mlog and self.mlog.wm_in_id:
return self.mlog.wm_in.defect
return None
def has_legacy_defect_count(self):
return any(getattr(self, f.name) > 0 for f in Mlogb._meta.fields if 'count_n_' in f.name)
def sync_inherited_defect(self, cal_count=True):
inherited_qs = MlogbDefect.objects.filter(mlogb=self, is_inherited=True)
if MlogbDefect.objects.filter(mlogb=self, is_inherited=False).exists() or self.has_legacy_defect_count():
inherited_qs.delete()
return
defect = self.get_default_inherited_defect()
if defect is None:
inherited_qs.delete()
return
count = self.count_real
inherited, _ = MlogbDefect.objects.get_or_create(
mlogb=self,
defect=defect,
is_inherited=True,
)
inherited.count = count
inherited.count_has = count
inherited.save(update_fields=["count", "count_has"])
inherited_qs.exclude(id=inherited.id).delete()
if cal_count:
self.cal_count_notok(cal_mlog=False)
class MlogbDefect(BaseModel):
"""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
from apps.wpm.models import Mlogb, Mlogbw, MlogbDefect, MlogUser
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)
mlog__is_fix=False, batch=batch, need_inout=True).order_by("mlog__submit_time")
if mlogb1_qs.exists():
data[f"{mgroup_name}_日期"] = []
data[f"{mgroup_name}_操作人"] = []
@ -38,6 +38,7 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_ok_full"] = 0
data[f"{mgroup_name}_count_pn_jgqbl"] = 0
mlogb_q_ids = []
cal_mlog = []
for item in mlogb1_qs:
# 找到对应的输入
mlogb_from:Mlogb = item.mlogb_from
@ -51,6 +52,13 @@ def main(batch: str, mgroup_obj:Mgroup=None):
data[f"{mgroup_name}_count_pn_jgqbl"] += 0
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

View File

@ -228,10 +228,11 @@ 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"]
fields = ["id", "defect_name", "count", "mlogb", "defect", "defect_okcate", "count_has", "is_inherited"]
read_only_fields = EXCLUDE_FIELDS_BASE + ["mlogb"]
extra_kwargs = {
'count_has': {'required': False},
'is_inherited': {'required': False},
}
def validate(self, attrs):
@ -477,6 +478,7 @@ 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
@ -567,7 +569,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).delete()
MlogbDefect.objects.filter(mlogb__mlog=instance, is_inherited=False).delete()
mlogb_defect_objects = [
MlogbDefect(**{**item, "mlogb": mox, "id": idWorker.get_id()})
for item in mlogdefect if item["count"] > 0
@ -575,6 +577,7 @@ 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
@ -1111,6 +1114,8 @@ 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

@ -977,6 +977,8 @@ 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):