mirror of
https://github.com/huge-dream/django-vue3-admin.git
synced 2026-10-02 16:37:50 +00:00
1833 lines
77 KiB
Python
1833 lines
77 KiB
Python
import logging
|
||
from collections import defaultdict
|
||
from decimal import Decimal
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from django.db import transaction
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
from rest_framework.decorators import action
|
||
from rest_framework import serializers
|
||
|
||
from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse
|
||
from dvadmin.utils.viewset import CustomModelViewSet
|
||
from apps.pisadmin.basicinfo.models import Company, EmailNotice, SystemNoRule
|
||
from apps.pisadmin.basicinfo.views.email_template import (
|
||
TEMPLATE_RFS_PUBLISH,
|
||
build_context_rfs_publish,
|
||
render_email,
|
||
)
|
||
from apps.pisadmin.basicinfo.views.email_utils import send_bids_publish_notice_to_supplier, send_email_notice
|
||
|
||
from apps.pissupplier.models import (
|
||
QuotationMaster,
|
||
QuotationMaterial,
|
||
QuotationProcess,
|
||
QuotationOther,
|
||
QuotationProfit,
|
||
QuotationItem,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _fmt_dt_for_inquiry_quotation(dt: Optional[datetime]) -> Optional[str]:
|
||
if not dt:
|
||
return None
|
||
if timezone.is_naive(dt):
|
||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
def _quotation_master_status_label(status) -> str:
|
||
"""与 ``QuotationMaster.STATUS_CHOICES`` 一致的可读文案。"""
|
||
labels = {1: "待报价", 2: "报价中", 3: "已报价", 4: "已过期"}
|
||
try:
|
||
st = int(status) if status is not None else 1
|
||
except (TypeError, ValueError):
|
||
return "—"
|
||
return labels.get(st, str(st))
|
||
|
||
|
||
def build_invited_supplier_quotation_rows(inquiry) -> list:
|
||
"""询价单发布后各受邀供应商报价单当前状态(与 ``QuotationMaster`` 一致,供操作日志嵌套表使用)。"""
|
||
inquiry_no = (inquiry.inquiry_no or "").strip()
|
||
if not inquiry_no:
|
||
return []
|
||
|
||
name_map: dict[str, str] = {}
|
||
for inv in InquirySupplier.objects.filter(inquiry_no=inquiry_no).only("supplier_code", "supplier_name"):
|
||
code = (inv.supplier_code or "").strip()
|
||
if not code:
|
||
continue
|
||
n = (inv.supplier_name or "").strip()
|
||
prev = name_map.get(code, "")
|
||
if len(n) > len(prev):
|
||
name_map[code] = n
|
||
|
||
rows_out: list[dict] = []
|
||
qms = QuotationMaster.objects.filter(inquiry_no=inquiry_no).order_by("supplier_code", "autoid")
|
||
for qm in qms:
|
||
code = (qm.supplier_code or "").strip()
|
||
full_name = (name_map.get(code) or (qm.supplier_name or "").strip() or code or "—").strip()
|
||
|
||
st = int(qm.status or 1)
|
||
if st == 3 and qm.quotetime:
|
||
op_time = _fmt_dt_for_inquiry_quotation(qm.quotetime)
|
||
else:
|
||
op_time = _fmt_dt_for_inquiry_quotation(qm.creattime) if qm.creattime else None
|
||
|
||
if st == 3:
|
||
op_user = (qm.quoteuser or "").strip() or "—"
|
||
else:
|
||
op_user = "—"
|
||
|
||
rows_out.append(
|
||
{
|
||
"supplier_full_name": full_name,
|
||
"quotation_status": _quotation_master_status_label(qm.status),
|
||
"operation_time": op_time or "—",
|
||
"operator": op_user,
|
||
"quotation_no": (qm.quotation_no or "").strip(),
|
||
}
|
||
)
|
||
return rows_out
|
||
|
||
|
||
from .models import (
|
||
MiscProcurementMaterialInfo,
|
||
MiscProcurementStationInfo,
|
||
MiscProcMaterial,
|
||
MiscLowPriceHeader,
|
||
MiscLowPriceDetail,
|
||
MiscNegotiationRecords,
|
||
RFQOperationLogs,
|
||
Inquiry,
|
||
InquirySupplier,
|
||
InquiryAttachment,
|
||
InquiryMaterialCost,
|
||
InquiryProcessCost,
|
||
InquiryOtherCost,
|
||
InquiryProfitCost,
|
||
InquiryRfqItem,
|
||
CostEstimateTemplateHead,
|
||
CostEstimateTemplateBody,
|
||
MiscProcMaterialMinPrices,
|
||
MiscProcProcessingMinPrices,
|
||
)
|
||
from .serializers import (
|
||
MiscMaterialSerializer,
|
||
MiscMaterialCreateUpdateSerializer,
|
||
MiscStationSerializer,
|
||
MiscStationCreateUpdateSerializer,
|
||
MiscPartSerializer,
|
||
MiscPartCreateUpdateSerializer,
|
||
MiscLowPriceHeaderSerializer,
|
||
MiscLowPriceDetailSerializer,
|
||
MiscNegotiationRecordsSerializer,
|
||
MiscNegotiationSaveSerializer,
|
||
RFQOperationLogsSerializer,
|
||
InquirySerializer,
|
||
InquirySupplierSerializer,
|
||
InquiryAttachmentSerializer,
|
||
InquiryMaterialCostSerializer,
|
||
InquiryProcessCostSerializer,
|
||
InquiryOtherCostSerializer,
|
||
InquiryProfitCostSerializer,
|
||
InquiryRfqItemSerializer,
|
||
CostEstimateTemplateHeadSerializer,
|
||
CostEstimateTemplateNewVersionSerializer,
|
||
create_cost_template_new_version,
|
||
MiscProcMaterialMinPricesSerializer,
|
||
MiscProcProcessingMinPricesSerializer,
|
||
)
|
||
|
||
|
||
def _negotiation_totals_from_quotation_item(quotation_no: str, part_id: str):
|
||
"""从杂采报价单上阶物料明细取议价前含税/不含税总价(与 part_id 匹配行)。"""
|
||
if not quotation_no or not part_id:
|
||
return None, None
|
||
item = (
|
||
QuotationItem.objects.filter(quotation_no=quotation_no, part_id=part_id)
|
||
.order_by("autoid")
|
||
.first()
|
||
)
|
||
if not item:
|
||
return None, None
|
||
return item.total_price_excl_tax, item.total_price_incl_tax
|
||
|
||
|
||
def _safe_decimal(v):
|
||
if v is None:
|
||
return None
|
||
try:
|
||
return Decimal(str(v))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _get_earliest_quotation(quotations: list) -> tuple:
|
||
"""从报价单列表中获取最早提交的报价单(按 quotetime 或 creattime 排序)"""
|
||
if not quotations:
|
||
return None, None
|
||
|
||
earliest = None
|
||
earliest_time = None
|
||
earliest_qn = None
|
||
|
||
for q in quotations:
|
||
q_time = getattr(q, 'quotetime', None) or getattr(q, 'creattime', None)
|
||
qn = getattr(q, 'quotation_no', None) or getattr(q, 'quotationNo', None)
|
||
|
||
if q_time is None:
|
||
continue
|
||
|
||
if earliest is None or q_time < earliest_time:
|
||
earliest = q
|
||
earliest_time = q_time
|
||
earliest_qn = qn
|
||
|
||
return earliest, earliest_qn
|
||
|
||
|
||
def _resolve_inquiry_primary_part_id(inquiry: Inquiry) -> str:
|
||
"""与采购端比价弹窗一致:上阶物料首行料号。"""
|
||
first = InquiryRfqItem.objects.filter(inquiry_no=inquiry).order_by("id").first()
|
||
if not first:
|
||
return ""
|
||
return (first.part_id or "").strip()
|
||
|
||
|
||
def _clip_price_str(value, max_len: int = 10) -> str:
|
||
s = str(value).strip() if value is not None else ""
|
||
return s[:max_len]
|
||
|
||
|
||
def _clip_field(value, max_len: int) -> str:
|
||
return str(value or "").strip()[:max_len]
|
||
|
||
|
||
def _norm_low_price_src(s: str, max_len: int = 200) -> str:
|
||
return (s or "").strip()[:max_len]
|
||
|
||
|
||
def _material_cost_header_souce_no(weight_src: Optional[str], unit_src: Optional[str]) -> str:
|
||
"""主表材料行:聚合次表来源;W=最低重量对应报价单号,U=最低单价对应报价单号或杂采厂区代码。"""
|
||
parts = []
|
||
if weight_src:
|
||
parts.append(f"W:{_norm_low_price_src(weight_src)}")
|
||
if unit_src:
|
||
parts.append(f"U:{_norm_low_price_src(unit_src)}")
|
||
return _norm_low_price_src(";".join(parts))
|
||
|
||
|
||
def _rfq_qty_decimal(inquiry: Inquiry, pid: str) -> Optional[Decimal]:
|
||
"""上阶物料需求数量;无则返回 None。"""
|
||
pid = (pid or "").strip()
|
||
if not pid:
|
||
return None
|
||
it = (
|
||
InquiryRfqItem.objects.filter(inquiry_no=inquiry, part_id=pid)
|
||
.order_by("id")
|
||
.first()
|
||
)
|
||
if it is None or it.qty is None:
|
||
return None
|
||
try:
|
||
return Decimal(str(it.qty))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
|
||
"""
|
||
制程最低价落库(与比价展开明细一致):
|
||
- 先比较所有报价单的最低价。
|
||
- 如果**所有报价单最低价相等**,则取**最早提交报价单**的最低价。
|
||
- 如果**最低价不一致**,则取**所有报价单中的最小值**。
|
||
- 材料:按材质分组,每组单独计算最低重量/单价,再汇总所有材质的材料费用总和。
|
||
- 加工:每份报价单加工费合计后取最小值。
|
||
- 材料/加工最低价仅更新 `MiscLowPriceHeader`/`MiscLowPriceDetail` 记录表,
|
||
`MiscProcMaterialMinPrices`/`MiscProcProcessingMinPrices` 信息表仅在核价完成后更新(暂不实现)。
|
||
"""
|
||
inquiry_no = (inquiry.inquiry_no or "").strip()
|
||
if not inquiry_no:
|
||
return
|
||
|
||
# 获取所有报价单(排除已过期 status=4)
|
||
qm_qs = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4)
|
||
qn_list = list(qm_qs.values_list("quotation_no", flat=True))
|
||
souce_ref = inquiry_no[:20]
|
||
|
||
# 获取所有报价单详情,用于判断最早提交报价单
|
||
all_quotations = list(qm_qs.order_by("creattime", "quotetime"))
|
||
|
||
raw_parts = [
|
||
str(x).strip()
|
||
for x in InquiryRfqItem.objects.filter(inquiry_no=inquiry)
|
||
.values_list("part_id", flat=True)
|
||
.distinct()
|
||
if x is not None and str(x).strip() != ""
|
||
]
|
||
fallback = (part_id or "").strip()
|
||
part_ids = raw_parts if raw_parts else ([fallback] if fallback else [])
|
||
if not part_ids:
|
||
return
|
||
|
||
for pid in part_ids:
|
||
MiscLowPriceHeader.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete()
|
||
MiscLowPriceDetail.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete()
|
||
|
||
# 获取该料号的所有报价单(用于判断最早提交报价单)
|
||
pid_quotations = [q for q in all_quotations if q.quotation_no in qn_list]
|
||
|
||
# —— 材料:按材质规格分组,每组次表两行(重量、单价);主表 = 所有分组的(最低重量×最低单价×数量)之和。
|
||
# 如果所有报价单材料费用相等,则取最早提交报价单;否则取最小值。
|
||
# FK 须用报价单单号列表:quotation_no__in=QuerySet(QuotationMaster) 会按主键匹配,导致材料行查不到。
|
||
materials = QuotationMaterial.objects.filter(quotation_no__in=qn_list, part_id=pid)
|
||
|
||
# 按材质规格分组收集候选值
|
||
from collections import defaultdict
|
||
spec_weight_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list)
|
||
spec_unit_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list)
|
||
spec_quote_data: dict[str, dict[str, dict]] = {}
|
||
|
||
for m in materials:
|
||
qn_src = getattr(m, "quotation_no_id", None) or ""
|
||
qn_src = str(qn_src).strip()
|
||
qn = getattr(m, "quotation_no_id", None) or qn_src
|
||
spec = (getattr(m, "material_spec", None) or "").strip() or "-"
|
||
|
||
# 记录每个材质规格下每个报价单的数据,用于判断是否所有报价单相等
|
||
if spec not in spec_quote_data:
|
||
spec_quote_data[spec] = {}
|
||
if qn not in spec_quote_data[spec]:
|
||
spec_quote_data[spec][qn] = {}
|
||
|
||
if m.weight is not None:
|
||
try:
|
||
w = Decimal(str(m.weight))
|
||
spec_weight_candidates[spec].append((w, qn_src))
|
||
spec_quote_data[spec][qn]['weight'] = w
|
||
except Exception:
|
||
pass
|
||
if m.unit_price is not None:
|
||
try:
|
||
up = Decimal(str(m.unit_price))
|
||
spec_unit_candidates[spec].append((up, qn_src))
|
||
spec_quote_data[spec][qn]['unit_price'] = up
|
||
except Exception:
|
||
pass
|
||
if m.material_cost is not None:
|
||
try:
|
||
mc = Decimal(str(m.material_cost))
|
||
spec_quote_data[spec][qn]['material_cost'] = mc
|
||
except Exception:
|
||
pass
|
||
|
||
# 杂采材料信息:按「材质」与报价 material_spec 对齐;不得把其它材质的最低单价并入本组
|
||
misc_by_spec: dict[str, list[tuple[Decimal, str]]] = defaultdict(list)
|
||
for mi in MiscProcurementMaterialInfo.objects.filter(status=1).only("price", "factory", "materialtype"):
|
||
if mi.price is None:
|
||
continue
|
||
mat = (getattr(mi, "materialtype", None) or "").strip()
|
||
if not mat:
|
||
continue
|
||
try:
|
||
up = Decimal(str(mi.price))
|
||
fac = (getattr(mi, "factory", None) or "").strip() or "MISC"
|
||
misc_by_spec[mat].append((up, fac))
|
||
except Exception:
|
||
pass
|
||
|
||
qty_dec = _rfq_qty_decimal(inquiry, pid)
|
||
total_material_low = Decimal("0")
|
||
all_src_w_list: list[str] = []
|
||
all_src_up_list: list[str] = []
|
||
|
||
# 获取所有材质规格(有重量或单价数据的)
|
||
all_specs = set(spec_weight_candidates.keys()) | set(spec_unit_candidates.keys())
|
||
|
||
for spec in all_specs:
|
||
weight_candidates = spec_weight_candidates.get(spec, [])
|
||
unit_candidates = spec_unit_candidates.get(spec, [])
|
||
|
||
# 仅与同 material_spec 一致的杂采材质行参与该组最低价
|
||
unit_candidates = unit_candidates + list(misc_by_spec.get(spec, []))
|
||
|
||
# 按材质分组判断所有报价单是否相等
|
||
spec_quote_values = spec_quote_data.get(spec, {})
|
||
all_quotes_equal = True
|
||
first_value = None
|
||
|
||
for qn, data in spec_quote_values.items():
|
||
# 检查重量、单价、材料费用是否都相等
|
||
current_values = []
|
||
if 'weight' in data:
|
||
current_values.append(data['weight'])
|
||
if 'unit_price' in data:
|
||
current_values.append(data['unit_price'])
|
||
if 'material_cost' in data:
|
||
current_values.append(data['material_cost'])
|
||
|
||
if not current_values:
|
||
continue
|
||
|
||
if first_value is None:
|
||
first_value = current_values[0]
|
||
else:
|
||
for val in current_values:
|
||
if abs(val - first_value) > Decimal('0.0001'):
|
||
all_quotes_equal = False
|
||
break
|
||
if not all_quotes_equal:
|
||
break
|
||
|
||
min_w_row = min(weight_candidates, key=lambda t: (t[0], t[1])) if weight_candidates else None
|
||
min_up_row = min(unit_candidates, key=lambda t: (t[0], t[1])) if unit_candidates else None
|
||
min_w = min_w_row[0] if min_w_row else None
|
||
min_up = min_up_row[0] if min_up_row else None
|
||
src_w = min_w_row[1] if min_w_row else None
|
||
src_up = min_up_row[1] if min_up_row else None
|
||
|
||
# 如果该材质分组的所有报价单相等,则取最早提交报价单的数据
|
||
if all_quotes_equal and len(pid_quotations) > 0 and min_w is not None and min_up is not None:
|
||
earliest, earliest_qn = _get_earliest_quotation(pid_quotations)
|
||
if earliest_qn:
|
||
# 找到最早报价单对应的具体数据
|
||
earliest_data = spec_quote_data.get(spec, {}).get(earliest_qn)
|
||
if earliest_data:
|
||
if 'weight' in earliest_data:
|
||
min_w = earliest_data['weight']
|
||
if 'unit_price' in earliest_data:
|
||
min_up = earliest_data['unit_price']
|
||
src_w = earliest_qn
|
||
src_up = earliest_qn
|
||
|
||
# 判断是否来自杂采材料信息(本材质分组)
|
||
is_misc_src = False
|
||
if min_up is not None and misc_by_spec.get(spec):
|
||
for misc_up, misc_fac in misc_by_spec[spec]:
|
||
if abs(min_up - misc_up) < Decimal("0.0001"):
|
||
is_misc_src = True
|
||
break
|
||
|
||
# 写入次表:最低重量
|
||
if min_w is not None:
|
||
MiscLowPriceDetail.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
cost_type="1",
|
||
material_spec=spec[:50],
|
||
item_no="1",
|
||
value=_clip_price_str(min_w),
|
||
souce_no=_norm_low_price_src((src_w or souce_ref)),
|
||
)
|
||
if src_w:
|
||
all_src_w_list.append(str(src_w))
|
||
|
||
# 写入次表:最低单价
|
||
if min_up is not None:
|
||
MiscLowPriceDetail.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
cost_type="1",
|
||
material_spec=spec[:50],
|
||
item_no="2",
|
||
value=_clip_price_str(min_up),
|
||
souce_no=_norm_low_price_src((src_up or souce_ref)) if not is_misc_src else _norm_low_price_src(str(src_up or souce_ref)),
|
||
)
|
||
if src_up:
|
||
all_src_up_list.append(str(src_up))
|
||
|
||
# 累加到总材料费用
|
||
if min_w is not None and min_up is not None and qty_dec is not None and qty_dec > 0:
|
||
spec_material_low = min_w * min_up * qty_dec
|
||
total_material_low += spec_material_low
|
||
|
||
# 写入主表材料行:所有分组的材料费用总和
|
||
if total_material_low > 0:
|
||
# 聚合来源:W:所有重量来源; U:所有单价来源
|
||
aggregated_src_w = ",".join(sorted(set(all_src_w_list))) if all_src_w_list else None
|
||
aggregated_src_up = ",".join(sorted(set(all_src_up_list))) if all_src_up_list else None
|
||
MiscLowPriceHeader.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
souce_no=_material_cost_header_souce_no(aggregated_src_w, aggregated_src_up) or None,
|
||
cost_type="1",
|
||
item_no="材料",
|
||
min_price=_clip_price_str(total_material_low),
|
||
)
|
||
|
||
# —— 加工:仅主表一行,无次表、不按工站。每份报价单对该料号加工费合计(空/缺省按 0),再取最小;
|
||
# 如果所有报价单加工费合计相等,则取最早提交报价单;否则取最小值。
|
||
proc_totals: list[tuple[Decimal, str]] = []
|
||
for qn in qn_list:
|
||
total = Decimal("0")
|
||
for p in QuotationProcess.objects.filter(quotation_no=qn, part_id=pid):
|
||
if p.process_price is not None:
|
||
try:
|
||
total += Decimal(str(p.process_price))
|
||
except Exception:
|
||
pass
|
||
proc_totals.append((total, str(qn).strip()))
|
||
|
||
if proc_totals:
|
||
# 检查所有加工费是否相等
|
||
all_equal = all(abs(t[0] - proc_totals[0][0]) < Decimal('0.0001') for t in proc_totals)
|
||
|
||
if all_equal and len(pid_quotations) > 0:
|
||
# 所有报价加工费相等,取最早提交报价单
|
||
earliest, earliest_qn = _get_earliest_quotation(pid_quotations)
|
||
if earliest_qn:
|
||
best_total = proc_totals[0][0] # 任意一个都相等
|
||
best_qn = earliest_qn
|
||
else:
|
||
best_total, best_qn = min(proc_totals, key=lambda t: (t[0], t[1]))
|
||
else:
|
||
# 加工费不一致或无报价单,取最小值
|
||
best_total, best_qn = min(proc_totals, key=lambda t: (t[0], t[1]))
|
||
else:
|
||
best_total = Decimal("0")
|
||
best_qn = ""
|
||
|
||
MiscLowPriceHeader.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
souce_no=_norm_low_price_src(best_qn) if best_qn else (souce_ref or None),
|
||
cost_type="2",
|
||
item_no="加工",
|
||
min_price=_clip_price_str(best_total),
|
||
)
|
||
|
||
# —— 包装费 / 运输费(报价单单号列表;兼容 part_id 大小写;无子表时回退上阶物料 total_other_expense)——
|
||
others = QuotationOther.objects.filter(quotation_no__in=qn_list).filter(
|
||
Q(part_id=pid) | Q(part_id__iexact=(pid or "").strip())
|
||
)
|
||
pkg_vals = []
|
||
tr_vals = []
|
||
for o in others:
|
||
if o.packaging_cost is not None:
|
||
try:
|
||
pkg_vals.append(Decimal(str(o.packaging_cost)))
|
||
except Exception:
|
||
pass
|
||
if o.transportation_cost is not None:
|
||
try:
|
||
tr_vals.append(Decimal(str(o.transportation_cost)))
|
||
except Exception:
|
||
pass
|
||
if not pkg_vals and not tr_vals:
|
||
for qn in qn_list:
|
||
it = (
|
||
QuotationItem.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first()
|
||
or QuotationItem.objects.filter(quotation_no=qn).order_by("autoid").first()
|
||
)
|
||
if it is None or it.total_other_expense is None:
|
||
continue
|
||
try:
|
||
v = Decimal(str(it.total_other_expense))
|
||
except Exception:
|
||
continue
|
||
pkg_vals.append(v)
|
||
tr_vals.append(v)
|
||
|
||
if pkg_vals:
|
||
MiscLowPriceHeader.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
souce_no=souce_ref or None,
|
||
cost_type="3",
|
||
item_no="包装费",
|
||
min_price=_clip_price_str(min(pkg_vals)),
|
||
)
|
||
if tr_vals:
|
||
MiscLowPriceHeader.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
souce_no=souce_ref or None,
|
||
cost_type="4",
|
||
item_no="运输费",
|
||
min_price=_clip_price_str(min(tr_vals)),
|
||
)
|
||
|
||
# —— 利润率 cost_type=6(杂采无管销研 cost_type=5,不落库)——
|
||
pr_vals = []
|
||
for qn in qn_list:
|
||
it = QuotationItem.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first()
|
||
if it is None:
|
||
it = QuotationItem.objects.filter(quotation_no=qn).order_by("autoid").first()
|
||
if it is not None and it.profit_rate is not None and str(it.profit_rate).strip() != "":
|
||
v = _safe_decimal(it.profit_rate)
|
||
if v is not None:
|
||
pr_vals.append(v)
|
||
continue
|
||
pf = QuotationProfit.objects.filter(quotation_no=qn, part_id=pid).order_by("autoid").first()
|
||
if pf is not None and pf.profit_rate is not None:
|
||
v = _safe_decimal(pf.profit_rate)
|
||
if v is not None:
|
||
pr_vals.append(v)
|
||
if pr_vals:
|
||
MiscLowPriceHeader.objects.create(
|
||
inquiry_no=inquiry_no,
|
||
part_id=pid,
|
||
souce_no=souce_ref or None,
|
||
cost_type="6",
|
||
item_no="利润率",
|
||
min_price=_clip_price_str(min(pr_vals)),
|
||
)
|
||
|
||
|
||
class MiscMaterialViewSet(CustomModelViewSet):
|
||
queryset = MiscProcurementMaterialInfo.objects.all()
|
||
serializer_class = MiscMaterialSerializer
|
||
create_serializer_class = MiscMaterialCreateUpdateSerializer
|
||
update_serializer_class = MiscMaterialCreateUpdateSerializer
|
||
search_fields = ["materialtype", "factory"]
|
||
ordering = ["-create_datetime"]
|
||
# 与询价单 `Inquiry.company_code` 一致,须精确匹配 `factory`,避免 sz_avc 与 sz_avcx 串数据
|
||
filter_fields = ("factory", "status", "materialtype")
|
||
|
||
|
||
class MiscStationViewSet(CustomModelViewSet):
|
||
queryset = MiscProcurementStationInfo.objects.all()
|
||
serializer_class = MiscStationSerializer
|
||
create_serializer_class = MiscStationCreateUpdateSerializer
|
||
update_serializer_class = MiscStationCreateUpdateSerializer
|
||
search_fields = ["stationname", "stationcode", "company_code"]
|
||
ordering = ["-create_datetime"]
|
||
filter_fields = ("company_code", "status", "stationcode", "stationname")
|
||
|
||
|
||
class MiscPartViewSet(CustomModelViewSet):
|
||
queryset = MiscProcMaterial.objects.all()
|
||
serializer_class = MiscPartSerializer
|
||
create_serializer_class = MiscPartCreateUpdateSerializer
|
||
update_serializer_class = MiscPartCreateUpdateSerializer
|
||
search_fields = ["partid", "partid_name", "company_code"]
|
||
ordering = ["-create_datetime"]
|
||
|
||
|
||
class CostEstimateTemplateViewSet(CustomModelViewSet):
|
||
"""成本估算模板主表;status:0 未确认 / 1 已确认 / 2 作废(列表默认不含作废)。"""
|
||
|
||
queryset = CostEstimateTemplateHead.objects.all()
|
||
serializer_class = CostEstimateTemplateHeadSerializer
|
||
filter_fields = ("template_no", "template_name", "procurement_category", "is_bom", "acti", "version", "status")
|
||
search_fields = ("template_no", "template_name", "template_desc")
|
||
ordering = ("-update_time", "-id")
|
||
|
||
# 与 models.CostEstimateTemplateHead.STATUS_CHOICES 中「作废」取值一致
|
||
TEMPLATE_STATUS_VOID = 2
|
||
TEMPLATE_STATUS_DRAFT = 0
|
||
TEMPLATE_STATUS_CONFIRMED = 1
|
||
|
||
@staticmethod
|
||
def _head_status_value(instance) -> int:
|
||
# status 为 0(未确认)时不能使用 (value or -1),否则 0 会被当成 falsy 变成 -1
|
||
v = getattr(instance, "status", None)
|
||
if v is None:
|
||
return -1
|
||
try:
|
||
return int(v)
|
||
except (TypeError, ValueError):
|
||
return -1
|
||
|
||
def get_queryset(self):
|
||
qs = super().get_queryset()
|
||
# 仅列表默认隐藏作废;retrieve/update 等需能按 id 加载作废行(否则筛选作废后无法查看详情)
|
||
if getattr(self, "action", None) != "list":
|
||
return qs
|
||
params = getattr(self.request, "query_params", getattr(self.request, "GET", {}))
|
||
status_raw = params.get("status")
|
||
# 列表默认不展示作废;查询区显式选「状态=作废」时传 status=2,不过滤以便列出作废行
|
||
if status_raw is not None and str(status_raw).strip() != "":
|
||
try:
|
||
st = int(status_raw)
|
||
except (TypeError, ValueError):
|
||
return qs.exclude(status=self.TEMPLATE_STATUS_VOID)
|
||
if st == self.TEMPLATE_STATUS_VOID:
|
||
return qs
|
||
return qs.exclude(status=self.TEMPLATE_STATUS_VOID)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
def update(self, request, *args, **kwargs):
|
||
instance = self.get_object()
|
||
st = self._head_status_value(instance)
|
||
if st == self.TEMPLATE_STATUS_VOID:
|
||
return ErrorResponse(msg="模板已作废,不可修改")
|
||
if st == self.TEMPLATE_STATUS_CONFIRMED:
|
||
return ErrorResponse(msg="已确认模板不可修改")
|
||
return super().update(request, *args, **kwargs)
|
||
|
||
def partial_update(self, request, *args, **kwargs):
|
||
instance = self.get_object()
|
||
st = self._head_status_value(instance)
|
||
if st == self.TEMPLATE_STATUS_VOID:
|
||
return ErrorResponse(msg="模板已作废,不可修改")
|
||
if st == self.TEMPLATE_STATUS_CONFIRMED:
|
||
return ErrorResponse(msg="已确认模板不可修改")
|
||
return super().partial_update(request, *args, **kwargs)
|
||
|
||
def destroy(self, request, *args, **kwargs):
|
||
return ErrorResponse(msg="成本模板不允许删除")
|
||
|
||
@action(methods=['delete'], detail=False)
|
||
def multiple_delete(self, request, *args, **kwargs):
|
||
return ErrorResponse(msg="成本模板不允许删除")
|
||
|
||
@action(methods=['post'], detail=True)
|
||
def confirm(self, request, *args, **kwargs):
|
||
"""未确认(0) → 已确认(1)。同模板编号下 version 低于当前行的非作废主表行一律改为作废(2)。"""
|
||
instance = self.get_object()
|
||
cur = self._head_status_value(instance)
|
||
if cur == self.TEMPLATE_STATUS_VOID:
|
||
return ErrorResponse(msg="作废模板不可确认")
|
||
if cur != self.TEMPLATE_STATUS_DRAFT:
|
||
return ErrorResponse(msg="仅未确认状态的模板可确认")
|
||
now = timezone.now()
|
||
username = getattr(getattr(request, "user", None), "username", None) or ""
|
||
uname = str(username)[:20] if username else ""
|
||
|
||
with transaction.atomic():
|
||
qs_old = CostEstimateTemplateHead.objects.filter(
|
||
template_no=instance.template_no,
|
||
version__lt=instance.version,
|
||
).exclude(status=self.TEMPLATE_STATUS_VOID)
|
||
voided_count = qs_old.count()
|
||
upd = {"status": self.TEMPLATE_STATUS_VOID, "update_time": now}
|
||
if uname:
|
||
upd["update_user"] = uname
|
||
qs_old.update(**upd)
|
||
|
||
instance.status = self.TEMPLATE_STATUS_CONFIRMED
|
||
instance.update_time = now
|
||
if uname:
|
||
instance.update_user = uname
|
||
instance.save(update_fields=["status", "update_time", "update_user"])
|
||
|
||
serializer = self.get_serializer(instance)
|
||
msg = "确认成功,已作废旧版本" if voided_count else "确认成功"
|
||
return DetailResponse(data=serializer.data, msg=msg)
|
||
|
||
@action(methods=["post"], detail=True, url_path="new_version")
|
||
def new_version(self, request, *args, **kwargs):
|
||
"""已确认模板派生新版本:同 template_no,version=max+1,status=0;请求体校验见 CostEstimateTemplateNewVersionSerializer。"""
|
||
source = self.get_object()
|
||
if self._head_status_value(source) != self.TEMPLATE_STATUS_CONFIRMED:
|
||
return ErrorResponse(msg="仅已确认模板可创建新版本")
|
||
sz = CostEstimateTemplateNewVersionSerializer(data=request.data, context={"request": request})
|
||
if not sz.is_valid():
|
||
return ErrorResponse(msg="参数校验失败", data=sz.errors)
|
||
try:
|
||
head = create_cost_template_new_version(source, sz.validated_data, request)
|
||
except serializers.ValidationError as e:
|
||
detail = getattr(e, "detail", None)
|
||
if isinstance(detail, dict):
|
||
first_msg = None
|
||
for v in detail.values():
|
||
if isinstance(v, list) and v:
|
||
first_msg = str(v[0])
|
||
break
|
||
if isinstance(v, str) and v.strip():
|
||
first_msg = v.strip()
|
||
break
|
||
return ErrorResponse(msg=first_msg or "创建新版本失败", data=detail)
|
||
return ErrorResponse(msg=str(detail or e))
|
||
out = CostEstimateTemplateHeadSerializer(head, context={"request": request})
|
||
return DetailResponse(data=out.data, msg="新版本创建成功")
|
||
|
||
|
||
class InquiryViewSet(CustomModelViewSet):
|
||
"""
|
||
杂采询价单 CRUD/发布等。
|
||
|
||
与供应商报价单联动:当 `pissupplier.QuotationMasterViewSet.sync_expired` 将超时报价置为已过期后,
|
||
若该询价下已无待报价/报价中的报价单,且主表状态仍为「发布」(3) 或「报价中」(4),
|
||
会由 `Inquiry.sync_to_quote_closed_when_no_open_quotations` 将询价单置为「报价结束」(5)。
|
||
"""
|
||
|
||
queryset = Inquiry.objects.all().prefetch_related(
|
||
"suppliers",
|
||
"attachments",
|
||
"material_costs",
|
||
"process_costs",
|
||
"other_costs",
|
||
"profit_costs",
|
||
"rfq_items",
|
||
)
|
||
serializer_class = InquirySerializer
|
||
filter_fields = (
|
||
"inquiry_no",
|
||
"title",
|
||
"purchase_type",
|
||
"template",
|
||
"template_version",
|
||
"status",
|
||
"buyer",
|
||
"buying_method",
|
||
)
|
||
search_fields = ("inquiry_no", "title", "material_type", "buyer", "remark")
|
||
ordering = ("-update_datetime",)
|
||
|
||
STATUS_OPEN = 1
|
||
STATUS_CONFIRMED = 2
|
||
STATUS_PUBLISHED = 3
|
||
STATUS_QUOTING = 4
|
||
STATUS_QUOTE_ENDED = 5
|
||
STATUS_BARGaining = 6
|
||
STATUS_NEGOTIATED = 7
|
||
STATUS_PRICE_REVIEW = 7
|
||
STATUS_APPROVED = 8
|
||
STATUS_AWARDED = 9
|
||
STATUS_VOID = 0
|
||
TEMPLATE_PREFILL_FIELD_MAP = {
|
||
"1": {
|
||
"material": "material_spec",
|
||
"materialspec": "material_spec",
|
||
"length": "length",
|
||
"width": "width",
|
||
"height": "height",
|
||
"specificgravity": "specific_gravity",
|
||
"qty": "qty",
|
||
"unitprice": "unit_price",
|
||
"materialcost": "material_cost",
|
||
"weight": "weight",
|
||
"remark": "remark",
|
||
},
|
||
"2": {
|
||
"processstation": "process_station",
|
||
"unit": "unit",
|
||
"unitrate": "unit_rate",
|
||
"processqty": "process_qty",
|
||
"processprice": "process_price",
|
||
"remark": "remark",
|
||
},
|
||
"3": {
|
||
"packagingcost": "packaging_cost",
|
||
"transportationcost": "transportation_cost",
|
||
},
|
||
"5": {
|
||
"profitrate": "profit_rate",
|
||
},
|
||
"6": {
|
||
"taxrate": "tax_rate",
|
||
},
|
||
"7": {
|
||
"partno": "part_id",
|
||
"desc": "product_name",
|
||
"productname": "product_name",
|
||
"unit": "unit",
|
||
"qty": "qty",
|
||
"price": "unit_price",
|
||
"unitprice": "unit_price",
|
||
"amount": "product_cost",
|
||
"totalmaterialcost": "total_material_cost",
|
||
"totalprocessingcost": "total_processing_cost",
|
||
"totalotherexpense": "total_other_expense",
|
||
"totalopexamt": "total_opex_amt",
|
||
"profitrate": "profit_rate",
|
||
"taxrate": "tax_rate",
|
||
"totalpriceexcltax": "total_price_excl_tax",
|
||
"totalpriceincltax": "total_price_incl_tax",
|
||
},
|
||
}
|
||
|
||
@staticmethod
|
||
def _clip(value, max_length):
|
||
text = str(value or "").strip()
|
||
return text[:max_length]
|
||
|
||
@staticmethod
|
||
def _normalize_template_key(value):
|
||
text = str(value or "").strip().lower()
|
||
return "".join(ch for ch in text if ch.isalnum())
|
||
|
||
def _get_request_username(self) -> str:
|
||
user = getattr(self.request, "user", None)
|
||
if not user:
|
||
return ""
|
||
return (
|
||
getattr(user, "username", "")
|
||
or getattr(user, "name", "")
|
||
or getattr(user, "nick_name", "")
|
||
or getattr(user, "nickname", "")
|
||
or ""
|
||
)
|
||
|
||
def _ensure_editable(self, instance):
|
||
if int(getattr(instance, "status", self.STATUS_OPEN) or self.STATUS_OPEN) != self.STATUS_OPEN:
|
||
return ErrorResponse(msg="当前询价单状态仅允许查看,不允许编辑或删除;如需修改请先还原为“开立”")
|
||
return None
|
||
|
||
def _save_status(
|
||
self,
|
||
instance,
|
||
*,
|
||
status,
|
||
confirm_user=None,
|
||
confirm_time=None,
|
||
release_user=None,
|
||
release_time=None,
|
||
comparison_user=None,
|
||
comparison_time=None,
|
||
operation_type=None,
|
||
operation_desc=None,
|
||
):
|
||
old_status = int(instance.status if instance.status is not None else 0)
|
||
current_time = timezone.now()
|
||
current_user = self._get_request_username() or getattr(instance, "update_user", None)
|
||
instance.status = status
|
||
instance.confirm_user = confirm_user
|
||
instance.confirm_time = confirm_time
|
||
instance.release_user = release_user
|
||
instance.release_time = release_time
|
||
if comparison_user is not None:
|
||
instance.comparison_user = comparison_user
|
||
if comparison_time is not None:
|
||
instance.comparison_time = comparison_time
|
||
instance.update_user = current_user
|
||
instance.update_time = current_time
|
||
instance.save(
|
||
update_fields=[
|
||
"status",
|
||
"confirm_user",
|
||
"confirm_time",
|
||
"release_user",
|
||
"release_time",
|
||
"comparison_user",
|
||
"comparison_time",
|
||
"update_user",
|
||
"update_time",
|
||
]
|
||
)
|
||
new_status = int(status)
|
||
if operation_type is not None and old_status != new_status:
|
||
RFQOperationLogs.try_append(
|
||
inquiry_no=instance.inquiry_no,
|
||
purchase_type=int(instance.purchase_type),
|
||
operation_type=operation_type,
|
||
operation_user=current_user or None,
|
||
quotation_no="-",
|
||
per_status=old_status,
|
||
cur_status=new_status,
|
||
operation_desc=operation_desc,
|
||
)
|
||
serializer = self.get_serializer(instance)
|
||
return DetailResponse(data=serializer.data, msg="状态更新成功")
|
||
|
||
def _generate_quotation_numbers(self, count: int, inquiry: Inquiry):
|
||
"""
|
||
杂采报价单号:复用 `SystemNoRule`(rule_code=miscQTS),与询价单 miscRFS 同源取号逻辑。
|
||
company_code 优先询价单字段,否则「通用」厂区。
|
||
"""
|
||
if count <= 0:
|
||
return []
|
||
company_code = (getattr(inquiry, "company_code", None) or "").strip() or SystemNoRule.DEFAULT_SYSTEM_NO_COMPANY_CODE
|
||
username = self._get_request_username() or None
|
||
return SystemNoRule.allocate_system_numbers(
|
||
company_code,
|
||
"miscQTS",
|
||
count,
|
||
username=username,
|
||
now=timezone.now(),
|
||
)
|
||
|
||
def _get_template_prefill_fields(self, template_no, template_version=None):
|
||
"""
|
||
根据询价单关联的成本模板(Inquiry.template = template_no,Inquiry.template_version = 主表 version),
|
||
找出「发布生成报价单时」允许从询价单带入到报价子表的字段名集合。
|
||
|
||
满足以下任一条件时,对应映射字段进入集合(从询价单带入报价子表),否则为空值(见 _pick_prefill_value):
|
||
- is_computed == 1(系统自动计算/带出)
|
||
- supplier_required == 1(带出不可修改)或 2(带出可修改)
|
||
|
||
template_version 为空时兼容旧数据:取该编号下「已确认」主表的最高 version。
|
||
"""
|
||
if not template_no:
|
||
return {}
|
||
|
||
tn = str(template_no).strip()
|
||
base_qs = CostEstimateTemplateHead.objects.filter(template_no=tn, status=1)
|
||
head = None
|
||
if template_version is not None and template_version != "":
|
||
try:
|
||
ver = int(template_version)
|
||
except (TypeError, ValueError):
|
||
ver = None
|
||
if ver is not None:
|
||
head = base_qs.filter(version=ver).first()
|
||
if head is None:
|
||
head = base_qs.order_by("-version", "-id").first()
|
||
if not head:
|
||
return {}
|
||
|
||
prefill_fields = {}
|
||
queryset = CostEstimateTemplateBody.objects.filter(
|
||
template_no=head.template_no,
|
||
version=head.version,
|
||
).filter(Q(is_computed=1) | Q(supplier_required__in=(1, 2))).values(
|
||
"cost_category", "item_no", "item_name_cn"
|
||
)
|
||
|
||
for row in queryset:
|
||
cost_category = str(row.get("cost_category") or "").strip()
|
||
mapping = self.TEMPLATE_PREFILL_FIELD_MAP.get(cost_category, {})
|
||
raw_keys = [row.get("item_no"), row.get("item_name_cn")]
|
||
for raw_key in raw_keys:
|
||
normalized = self._normalize_template_key(raw_key)
|
||
target_field = mapping.get(normalized)
|
||
if target_field:
|
||
prefill_fields.setdefault(cost_category, set()).add(target_field)
|
||
return prefill_fields
|
||
|
||
@staticmethod
|
||
def _pick_prefill_value(prefill_fields, cost_category, field_name, source_value, empty_default=None):
|
||
category_fields = prefill_fields.get(str(cost_category), set())
|
||
if field_name in category_fields:
|
||
return source_value
|
||
return empty_default
|
||
|
||
@staticmethod
|
||
def _resolve_purchaser_company_name(inquiry: Inquiry) -> str:
|
||
code = (getattr(inquiry, "company_code", None) or "").strip()
|
||
if code:
|
||
row = Company.objects.filter(company_code=code).only("company_name", "company_short_name").first()
|
||
if row:
|
||
return (row.company_name or row.company_short_name or code).strip()
|
||
return code
|
||
return ""
|
||
|
||
def _group_inquiry_suppliers(self, inquiry: Inquiry):
|
||
"""
|
||
按询价单供应商子表 `InquirySupplier` 汇总:同一 `supplier_code`(空代码时用行级占位)合并多料号 `part_id`,
|
||
供 `_create_supplier_quotations` 为每个供应商生成一份 `QuotationMaster`。
|
||
返回条目的 supplier_code / supplier_name / 联系人字段均来自子表行数据(截断至报价主表字段长度)。
|
||
采购端保存时,联系人/邮箱/电话由前端在「供应商用户信息」表中按 supplier_id 选择后写入子表,此处不再与主档做 ORM 关联校验。
|
||
"""
|
||
supplier_map = {}
|
||
for row in inquiry.suppliers.all():
|
||
clipped_code = self._clip(row.supplier_code, 20)
|
||
# 与 key 一致:无代码时用行级占位,保证写入 QuotationMaster.supplier_code 非空(序列化必填)
|
||
key = clipped_code or f"SUP-{row.id}"
|
||
current = supplier_map.setdefault(
|
||
key,
|
||
{
|
||
"supplier_code": clipped_code or key,
|
||
"supplier_name": self._clip(row.supplier_name, 20),
|
||
"contact_person": self._clip(row.contact_person, 20),
|
||
"contact_phone": self._clip(row.contact_phone, 20),
|
||
"contact_email": self._clip(row.contact_email, 20),
|
||
"part_ids": set(),
|
||
},
|
||
)
|
||
current["part_ids"].add(row.part_id)
|
||
# 合并多行时:若后续行补全了真实供应商代码,覆盖 SUP-* 占位
|
||
if clipped_code and (
|
||
not current.get("supplier_code")
|
||
or str(current["supplier_code"]).startswith("SUP-")
|
||
):
|
||
current["supplier_code"] = clipped_code
|
||
if not current["supplier_name"] and row.supplier_name:
|
||
current["supplier_name"] = self._clip(row.supplier_name, 20)
|
||
if not current["contact_person"] and row.contact_person:
|
||
current["contact_person"] = self._clip(row.contact_person, 20)
|
||
if not current["contact_phone"] and row.contact_phone:
|
||
current["contact_phone"] = self._clip(row.contact_phone, 20)
|
||
if not current["contact_email"] and row.contact_email:
|
||
current["contact_email"] = self._clip(row.contact_email, 20)
|
||
groups = list(supplier_map.values())
|
||
for g in groups:
|
||
# 子表未填名称时,用已确定的代码占位,避免报价主表 supplier_name 为空串
|
||
if not (g.get("supplier_name") or "").strip():
|
||
g["supplier_name"] = self._clip(g.get("supplier_code") or "", 20)
|
||
return groups
|
||
|
||
def _ensure_inquiry_has_supplier_groups(self, inquiry: Inquiry, *, empty_message: str):
|
||
"""
|
||
仅在「确认」时调用:供应商子表经 `_group_inquiry_suppliers` 汇总后须非空。
|
||
「发布」仅允许在已确认后进行,不再重复校验名单(与业务约定一致)。
|
||
"""
|
||
groups = self._group_inquiry_suppliers(inquiry)
|
||
if not groups:
|
||
raise serializers.ValidationError(empty_message)
|
||
return groups
|
||
|
||
def _create_supplier_quotations(self, inquiry: Inquiry):
|
||
"""
|
||
询价单发布:按供应商子表汇总结果,为每个供应商创建一张 `QuotationMaster`(及子表明细),
|
||
主表 `supplier_code`、`supplier_name` 与联系人信息与 `InquirySupplier` 对应数据一致(见 `_group_inquiry_suppliers`)。
|
||
名单是否在空已在确认环节校验,此处不再校验。
|
||
"""
|
||
supplier_groups = self._group_inquiry_suppliers(inquiry)
|
||
|
||
QuotationMaster.objects.filter(inquiry_no=inquiry.inquiry_no).delete()
|
||
|
||
quotation_numbers = self._generate_quotation_numbers(len(supplier_groups), inquiry)
|
||
prefill_fields = self._get_template_prefill_fields(
|
||
getattr(inquiry, "template", None),
|
||
getattr(inquiry, "template_version", None),
|
||
)
|
||
current_user = self._clip(
|
||
self._get_request_username() or getattr(inquiry, "release_user", None),
|
||
20,
|
||
) or None
|
||
current_time = timezone.now()
|
||
# 招标(2):供应商端报价单沿用「投标截止时间」作为有效报价截止;询价(1)用主表 quote_deadline。
|
||
quote_deadline = inquiry.quote_deadline
|
||
if int(getattr(inquiry, "buying_method", 1) or 1) == 2:
|
||
quote_deadline = getattr(inquiry, "bid_end_time", None) or quote_deadline
|
||
|
||
inquiry_materials = list(inquiry.material_costs.all())
|
||
inquiry_processes = list(inquiry.process_costs.all())
|
||
inquiry_others = list(inquiry.other_costs.all())
|
||
inquiry_profits = list(inquiry.profit_costs.all())
|
||
inquiry_items = list(inquiry.rfq_items.all())
|
||
|
||
material_bulk = []
|
||
process_bulk = []
|
||
other_bulk = []
|
||
profit_bulk = []
|
||
item_bulk = []
|
||
|
||
for quotation_no, supplier in zip(quotation_numbers, supplier_groups):
|
||
# 主表供应商字段与 InquirySupplier 子表一致(已由 _group_inquiry_suppliers 从子表汇总)
|
||
quotation = QuotationMaster.objects.create(
|
||
quotation_no=quotation_no,
|
||
inquiry_no=inquiry.inquiry_no,
|
||
supplier_code=supplier["supplier_code"],
|
||
supplier_name=supplier["supplier_name"],
|
||
contact_person=supplier["contact_person"] or None,
|
||
contact_phone=supplier["contact_phone"] or None,
|
||
contact_email=supplier["contact_email"] or None,
|
||
quote_deadline=quote_deadline,
|
||
buying_method=getattr(inquiry, "buying_method", None),
|
||
bid_start_time=getattr(inquiry, "bid_start_time", None),
|
||
bid_end_time=getattr(inquiry, "bid_end_time", None),
|
||
delivery_days=getattr(inquiry, "lead_time_days", None),
|
||
payment_method=getattr(inquiry, "payment_method", None),
|
||
status=1,
|
||
is_awarded=0,
|
||
createuser=current_user,
|
||
creattime=current_time,
|
||
remark=None,
|
||
)
|
||
part_ids = supplier["part_ids"]
|
||
|
||
for row in inquiry_materials:
|
||
if row.part_id in part_ids:
|
||
material_spec_src = self._pick_prefill_value(
|
||
prefill_fields, "1", "material_spec", row.material_spec, empty_default=""
|
||
)
|
||
material_bulk.append(
|
||
QuotationMaterial(
|
||
quotation_no=quotation,
|
||
part_id=row.part_id,
|
||
material_spec=self._clip(material_spec_src or "", 20),
|
||
length=self._pick_prefill_value(prefill_fields, "1", "length", row.length),
|
||
width=self._pick_prefill_value(prefill_fields, "1", "width", row.width),
|
||
height=self._pick_prefill_value(prefill_fields, "1", "height", row.height),
|
||
unit_price=self._pick_prefill_value(prefill_fields, "1", "unit_price", row.unit_price),
|
||
qty=self._pick_prefill_value(prefill_fields, "1", "qty", row.qty),
|
||
specific_gravity=self._pick_prefill_value(
|
||
prefill_fields, "1", "specific_gravity", row.specific_gravity
|
||
),
|
||
material_cost=self._pick_prefill_value(prefill_fields, "1", "material_cost", row.material_cost),
|
||
weight=self._pick_prefill_value(prefill_fields, "1", "weight", row.weight),
|
||
remark=self._pick_prefill_value(prefill_fields, "1", "remark", row.remark),
|
||
option_json=row.option_json if prefill_fields.get("1") else None,
|
||
)
|
||
)
|
||
|
||
for row in inquiry_processes:
|
||
if row.part_id in part_ids:
|
||
process_station_src = self._pick_prefill_value(
|
||
prefill_fields, "2", "process_station", row.process_station
|
||
)
|
||
process_bulk.append(
|
||
QuotationProcess(
|
||
quotation_no=quotation,
|
||
part_id=row.part_id,
|
||
process_station=self._clip(process_station_src, 20) if process_station_src else None,
|
||
unit=self._pick_prefill_value(prefill_fields, "2", "unit", row.unit),
|
||
unit_rate=self._pick_prefill_value(prefill_fields, "2", "unit_rate", row.unit_rate),
|
||
process_qty=self._pick_prefill_value(prefill_fields, "2", "process_qty", row.process_qty),
|
||
process_price=self._pick_prefill_value(prefill_fields, "2", "process_price", row.process_price),
|
||
remark=self._pick_prefill_value(prefill_fields, "2", "remark", row.remark),
|
||
option_json=row.option_json if prefill_fields.get("2") else None,
|
||
)
|
||
)
|
||
|
||
for row in inquiry_others:
|
||
if row.part_id in part_ids:
|
||
other_bulk.append(
|
||
QuotationOther(
|
||
quotation_no=quotation,
|
||
part_id=row.part_id,
|
||
packaging_cost=self._pick_prefill_value(prefill_fields, "3", "packaging_cost", row.packaging_cost),
|
||
transportation_cost=self._pick_prefill_value(prefill_fields, "3", "transportation_cost", row.transportation_cost),
|
||
)
|
||
)
|
||
|
||
for row in inquiry_profits:
|
||
if row.part_id in part_ids:
|
||
profit_bulk.append(
|
||
QuotationProfit(
|
||
quotation_no=quotation,
|
||
part_id=row.part_id,
|
||
tax_rate=self._pick_prefill_value(prefill_fields, "6", "tax_rate", row.tax_rate),
|
||
profit_rate=self._pick_prefill_value(prefill_fields, "5", "profit_rate", row.profit_rate),
|
||
)
|
||
)
|
||
|
||
for row in inquiry_items:
|
||
if row.part_id in part_ids:
|
||
# 上阶物料核心字段与询价 `InquiryRfqItem` 一致,发布时始终带入报价单(不依赖模板 prefill 是否勾选「产品明细」列)
|
||
product_name_src = row.product_name
|
||
unit_src = row.unit
|
||
qty_src = row.qty
|
||
unit_price_src = row.unit_price
|
||
item_bulk.append(
|
||
QuotationItem(
|
||
quotation_no=quotation,
|
||
part_id=row.part_id,
|
||
product_name=self._clip(product_name_src or "", 20),
|
||
unit=self._clip(unit_src or "", 10),
|
||
qty=int(qty_src) if qty_src is not None else 0,
|
||
is_bom=row.is_bom or 0,
|
||
unit_price=unit_price_src,
|
||
product_cost=self._pick_prefill_value(prefill_fields, "7", "product_cost", None),
|
||
total_material_cost=self._pick_prefill_value(prefill_fields, "7", "total_material_cost", row.total_material_cost),
|
||
total_processing_cost=self._pick_prefill_value(prefill_fields, "7", "total_processing_cost", row.total_processing_cost),
|
||
total_other_expense=self._pick_prefill_value(prefill_fields, "7", "total_other_expense", row.total_other_expense),
|
||
total_opex_amt=self._pick_prefill_value(prefill_fields, "7", "total_opex_amt", row.total_opex_amt),
|
||
profit_rate=self._pick_prefill_value(prefill_fields, "7", "profit_rate", row.profit_rate),
|
||
total_price_excl_tax=self._pick_prefill_value(prefill_fields, "7", "total_price_excl_tax", row.total_price_excl_tax),
|
||
tax_rate=self._pick_prefill_value(prefill_fields, "7", "tax_rate", row.tax_rate),
|
||
total_price_incl_tax=self._pick_prefill_value(prefill_fields, "7", "total_price_incl_tax", row.total_price_incl_tax),
|
||
is_awarded=0,
|
||
winning_bid_price=None,
|
||
)
|
||
)
|
||
|
||
if material_bulk:
|
||
QuotationMaterial.objects.bulk_create(material_bulk)
|
||
if process_bulk:
|
||
QuotationProcess.objects.bulk_create(process_bulk)
|
||
if other_bulk:
|
||
QuotationOther.objects.bulk_create(other_bulk)
|
||
if profit_bulk:
|
||
QuotationProfit.objects.bulk_create(profit_bulk)
|
||
if item_bulk:
|
||
QuotationItem.objects.bulk_create(item_bulk)
|
||
|
||
def perform_create(self, serializer):
|
||
inquiry = serializer.save()
|
||
RFQOperationLogs.try_append(
|
||
inquiry_no=inquiry.inquiry_no,
|
||
purchase_type=int(inquiry.purchase_type),
|
||
operation_type=1,
|
||
operation_user=self._get_request_username() or None,
|
||
quotation_no="-",
|
||
per_status=None,
|
||
cur_status=int(inquiry.status) if inquiry.status is not None else 1,
|
||
operation_desc="询价单创建",
|
||
)
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
def update(self, request, *args, **kwargs):
|
||
instance = self.get_object()
|
||
blocked = self._ensure_editable(instance)
|
||
if blocked:
|
||
return blocked
|
||
return super().update(request, *args, **kwargs)
|
||
|
||
def partial_update(self, request, *args, **kwargs):
|
||
instance = self.get_object()
|
||
blocked = self._ensure_editable(instance)
|
||
if blocked:
|
||
return blocked
|
||
return super().partial_update(request, *args, **kwargs)
|
||
|
||
def destroy(self, request, *args, **kwargs):
|
||
instance = self.get_object()
|
||
blocked = self._ensure_editable(instance)
|
||
if blocked:
|
||
return blocked
|
||
return super().destroy(request, *args, **kwargs)
|
||
|
||
@action(methods=["delete"], detail=False)
|
||
def multiple_delete(self, request, *args, **kwargs):
|
||
keys = request.data.get("keys", None)
|
||
if not keys:
|
||
return ErrorResponse(msg="未获取到keys字段")
|
||
locked = self.get_queryset().filter(id__in=keys).exclude(status=self.STATUS_OPEN)
|
||
if locked.exists():
|
||
return ErrorResponse(msg="仅“开立”状态的询价单允许删除")
|
||
self.get_queryset().filter(id__in=keys).delete()
|
||
return SuccessResponse(data=[], msg="删除成功")
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def confirm(self, request, pk=None):
|
||
instance = self.get_object()
|
||
if int(instance.status or self.STATUS_OPEN) != self.STATUS_OPEN:
|
||
return ErrorResponse(msg="只有“开立”状态的询价单才能确认")
|
||
try:
|
||
self._ensure_inquiry_has_supplier_groups(
|
||
instance, empty_message="请先维护询价单供应商名单后再确认"
|
||
)
|
||
except serializers.ValidationError as exc:
|
||
detail = getattr(exc, "detail", None)
|
||
if isinstance(detail, (list, tuple)) and detail:
|
||
msg = str(detail[0])
|
||
else:
|
||
msg = str(detail or exc)
|
||
return ErrorResponse(msg=msg)
|
||
current_user = self._get_request_username() or None
|
||
current_time = timezone.now()
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_CONFIRMED,
|
||
confirm_user=current_user,
|
||
confirm_time=current_time,
|
||
release_user=getattr(instance, "release_user", None),
|
||
release_time=getattr(instance, "release_time", None),
|
||
operation_type=2,
|
||
operation_desc="询价单确认",
|
||
)
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def restore(self, request, pk=None):
|
||
instance = self.get_object()
|
||
if int(instance.status or self.STATUS_OPEN) != self.STATUS_CONFIRMED:
|
||
return ErrorResponse(msg='只有“确认”状态的询价单才能还原为“开立”')
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_OPEN,
|
||
confirm_user=None,
|
||
confirm_time=None,
|
||
release_user=None,
|
||
release_time=None,
|
||
operation_type=4,
|
||
operation_desc="询价单还原为开立",
|
||
)
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def publish(self, request, pk=None):
|
||
instance = self.get_object()
|
||
if int(instance.status or self.STATUS_OPEN) != self.STATUS_CONFIRMED:
|
||
return ErrorResponse(msg='只有“确认”状态的询价单才能发布')
|
||
current_user = self._get_request_username() or None
|
||
current_time = timezone.now()
|
||
supplier_groups = self._group_inquiry_suppliers(instance)
|
||
supplier_count = len(supplier_groups)
|
||
try:
|
||
with transaction.atomic():
|
||
self._create_supplier_quotations(instance) # 发布询价单时按供应商关联表,逐个创建对应的报价单
|
||
self._notify_vendors_on_publish(instance) # 与子表汇总结果一致,逐供应商发送发布邮件
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_PUBLISHED,
|
||
confirm_user=getattr(instance, "confirm_user", None),
|
||
confirm_time=getattr(instance, "confirm_time", None),
|
||
release_user=current_user,
|
||
release_time=current_time,
|
||
operation_type=3,
|
||
operation_desc=f"询价单发布,共{supplier_count}个受邀供应商",
|
||
)
|
||
except serializers.ValidationError as exc:
|
||
detail = getattr(exc, "detail", None)
|
||
if isinstance(detail, (list, tuple)) and detail:
|
||
msg = str(detail[0])
|
||
else:
|
||
msg = str(detail or exc)
|
||
return ErrorResponse(msg=msg)
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def start_bargaining(self, request, pk=None):
|
||
"""开启比价议价:从报价结束状态进入比议价中;并写入比价-制程最低价主/次表。"""
|
||
instance = self.get_object()
|
||
if int(instance.status or self.STATUS_PUBLISHED) not in (self.STATUS_PUBLISHED, self.STATUS_QUOTING, self.STATUS_QUOTE_ENDED):
|
||
return ErrorResponse(msg='只有“发布”或“报价结束”状态的询价单才能开启比价')
|
||
current_user = self._get_request_username() or None
|
||
current_time = timezone.now()
|
||
part_id = _resolve_inquiry_primary_part_id(instance)
|
||
try:
|
||
with transaction.atomic():
|
||
if part_id:
|
||
_sync_misc_low_price_records(instance, part_id)
|
||
else:
|
||
logger.warning(
|
||
"start_bargaining: 询价单无上阶物料料号,跳过制程最低价落库 inquiry_no=%s",
|
||
instance.inquiry_no,
|
||
)
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_BARGaining,
|
||
confirm_user=getattr(instance, "confirm_user", None),
|
||
confirm_time=getattr(instance, "confirm_time", None),
|
||
release_user=getattr(instance, "release_user", None),
|
||
release_time=getattr(instance, "release_time", None),
|
||
comparison_user=current_user,
|
||
comparison_time=current_time,
|
||
operation_type=7,
|
||
operation_desc="开启比议价",
|
||
)
|
||
except Exception as exc:
|
||
logger.exception("start_bargaining 写入制程最低价失败")
|
||
return ErrorResponse(msg=f"开启比价失败:{exc}")
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def confirm_negotiation(self, request, pk=None):
|
||
"""确认议价:从比议价中进入议价确认/价格审核"""
|
||
instance = self.get_object()
|
||
if int(instance.status or self.STATUS_BARGaining) != self.STATUS_BARGaining:
|
||
return ErrorResponse(msg='只有“比议价中”状态的询价单才能确认议价')
|
||
part_id = _resolve_inquiry_primary_part_id(instance)
|
||
if not part_id:
|
||
return ErrorResponse(msg="询价单无上阶物料料号,无法确认比价")
|
||
try:
|
||
with transaction.atomic():
|
||
_sync_misc_low_price_records(instance, part_id)
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_NEGOTIATED,
|
||
confirm_user=getattr(instance, "confirm_user", None),
|
||
confirm_time=getattr(instance, "confirm_time", None),
|
||
release_user=getattr(instance, "release_user", None),
|
||
release_time=getattr(instance, "release_time", None),
|
||
comparison_user=getattr(instance, "comparison_user", None),
|
||
comparison_time=getattr(instance, "comparison_time", None),
|
||
operation_type=8,
|
||
operation_desc="议价审核提交(进入价格审核)",
|
||
)
|
||
except Exception as exc:
|
||
return ErrorResponse(msg=f"确认比价失败:{exc}")
|
||
|
||
@action(methods=["put"], detail=True)
|
||
def submit_price_audit(self, request, pk=None):
|
||
"""价格审核提交:由「价格审核」(7) 进入「核价通过」(8)。"""
|
||
instance = self.get_object()
|
||
if int(instance.status or 0) != self.STATUS_NEGOTIATED:
|
||
return ErrorResponse(msg='只有「价格审核」状态的询价单才能提交核价')
|
||
return self._save_status(
|
||
instance,
|
||
status=self.STATUS_APPROVED,
|
||
confirm_user=getattr(instance, "confirm_user", None),
|
||
confirm_time=getattr(instance, "confirm_time", None),
|
||
release_user=getattr(instance, "release_user", None),
|
||
release_time=getattr(instance, "release_time", None),
|
||
comparison_user=getattr(instance, "comparison_user", None),
|
||
comparison_time=getattr(instance, "comparison_time", None),
|
||
operation_type=9,
|
||
operation_desc="议价审核完成(核价通过)",
|
||
)
|
||
|
||
@action(methods=["get"], detail=True, url_path="negotiation_records")
|
||
def negotiation_records(self, request, pk=None):
|
||
"""按询价单号查询杂采议价记录(可选 part_id)。"""
|
||
instance = self.get_object()
|
||
part_id = (request.query_params.get("part_id") or "").strip()
|
||
if not part_id:
|
||
part_id = _resolve_inquiry_primary_part_id(instance)
|
||
qs = MiscNegotiationRecords.objects.filter(inquiry_no=instance.inquiry_no)
|
||
if part_id:
|
||
qs = qs.filter(part_id=part_id)
|
||
data = MiscNegotiationRecordsSerializer(qs.order_by("id"), many=True).data
|
||
return DetailResponse(data=data, msg="success")
|
||
|
||
@action(methods=["get"], detail=True, url_path="operation_logs")
|
||
def operation_logs(self, request, pk=None):
|
||
"""按询价单号查询该询价单全部操作日志(``pis_rfq_operation_logs``)。"""
|
||
instance = self.get_object()
|
||
qs = (
|
||
RFQOperationLogs.objects.filter(inquiry_no=instance.inquiry_no)
|
||
.order_by("-operation_time", "-create_datetime", "-id")
|
||
)
|
||
data = RFQOperationLogsSerializer(qs, many=True).data
|
||
return DetailResponse(data=data, msg="success")
|
||
|
||
@action(methods=["get"], detail=True, url_path="invited_supplier_quotations")
|
||
def invited_supplier_quotations(self, request, pk=None):
|
||
"""受邀供应商报价单一览(与发布生成的 ``QuotationMaster`` 一致,供操作日志嵌套表使用)。"""
|
||
instance = self.get_object()
|
||
data = build_invited_supplier_quotation_rows(instance)
|
||
return DetailResponse(data=data, msg="success")
|
||
|
||
@action(methods=["put"], detail=True, url_path="save_negotiation_records")
|
||
def save_negotiation_records(self, request, pk=None):
|
||
"""按报价单写入杂采议价记录:议价结果 + 该报价单议价前含税/不含税总价快照(来自上阶物料明细)。"""
|
||
instance = self.get_object()
|
||
serializer = MiscNegotiationSaveSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
part_id = (serializer.validated_data.get("part_id") or "").strip()
|
||
if not part_id:
|
||
part_id = _resolve_inquiry_primary_part_id(instance)
|
||
if not part_id:
|
||
return ErrorResponse(msg="询价单无上阶物料料号,无法保存议价记录")
|
||
records = serializer.validated_data.get("records") or []
|
||
username = self._get_request_username() or None
|
||
now = timezone.now()
|
||
with transaction.atomic():
|
||
for row in records:
|
||
qn = (row.get("quotation_no") or "").strip()
|
||
if not qn:
|
||
return ErrorResponse(msg="records 中每条须包含 quotation_no(报价单号)")
|
||
code = (row.get("supplier_code") or "").strip()
|
||
if not code:
|
||
qm = QuotationMaster.objects.filter(quotation_no=qn).only("supplier_code").first()
|
||
if qm:
|
||
code = (qm.supplier_code or "").strip()
|
||
is_awarded = int(row.get("is_awarded") or 0)
|
||
# 议价价格独立于是否中标:用户填写即落库(清空时前端传 null)
|
||
bp = row.get("bargaining_price")
|
||
snap_ex, snap_in = _negotiation_totals_from_quotation_item(qn, part_id)
|
||
tex = row.get("total_price_excl_tax")
|
||
tin = row.get("total_price_incl_tax")
|
||
if tex is None:
|
||
tex = snap_ex
|
||
if tin is None:
|
||
tin = snap_in
|
||
if tex is None:
|
||
tex = Decimal("0")
|
||
if tin is None:
|
||
tin = Decimal("0")
|
||
defaults = {
|
||
"supplier_code": code or None,
|
||
"is_awarded": is_awarded,
|
||
"bargaining_user": username,
|
||
"bargaining_time": now,
|
||
"bargaining_price": bp,
|
||
"quotation_no": qn,
|
||
"total_price_excl_tax": tex,
|
||
"total_price_incl_tax": tin,
|
||
}
|
||
qs = MiscNegotiationRecords.objects.filter(
|
||
inquiry_no=instance.inquiry_no,
|
||
part_id=part_id,
|
||
quotation_no=qn,
|
||
)
|
||
if qs.count() > 1:
|
||
keep_id = qs.order_by("id").first().id
|
||
qs.exclude(id=keep_id).delete()
|
||
obj = (
|
||
MiscNegotiationRecords.objects.filter(
|
||
inquiry_no=instance.inquiry_no,
|
||
part_id=part_id,
|
||
quotation_no=qn,
|
||
)
|
||
.order_by("id")
|
||
.first()
|
||
)
|
||
if obj:
|
||
for k, v in defaults.items():
|
||
setattr(obj, k, v)
|
||
obj.save()
|
||
else:
|
||
MiscNegotiationRecords.objects.create(
|
||
inquiry_no=instance.inquiry_no,
|
||
part_id=part_id,
|
||
**defaults,
|
||
)
|
||
out_qs = MiscNegotiationRecords.objects.filter(
|
||
inquiry_no=instance.inquiry_no,
|
||
part_id=part_id,
|
||
).order_by("id")
|
||
return DetailResponse(
|
||
data=MiscNegotiationRecordsSerializer(out_qs, many=True).data,
|
||
msg="议价记录已保存",
|
||
)
|
||
|
||
def _notify_vendors_on_publish(self, inquiry: Inquiry):
|
||
"""
|
||
按询价单供应商子表汇总(与 `_create_supplier_quotations` 相同的 `_group_inquiry_suppliers`),
|
||
每个供应商分组一封邮件,避免同一供应商多行子表重复发送。
|
||
"""
|
||
supplier_groups = self._group_inquiry_suppliers(inquiry)
|
||
if not supplier_groups:
|
||
return
|
||
|
||
purchaser_company_name = self._resolve_purchaser_company_name(inquiry)
|
||
is_bidding = int(getattr(inquiry, "buying_method", 1) or 1) == 2
|
||
|
||
for vendor in supplier_groups:
|
||
if not isinstance(vendor, dict):
|
||
continue
|
||
|
||
if is_bidding:
|
||
send_bids_publish_notice_to_supplier(
|
||
inquiry,
|
||
vendor,
|
||
purchaser_company_name=purchaser_company_name,
|
||
)
|
||
continue
|
||
|
||
supplier_name = (vendor.get("supplier_name") or "").strip()
|
||
email = (vendor.get("contact_email") or "").strip()
|
||
to_list = [email] if email else []
|
||
|
||
ctx = build_context_rfs_publish(
|
||
inquiry,
|
||
vendor,
|
||
purchaser_company_name=purchaser_company_name,
|
||
)
|
||
subject, body = render_email(TEMPLATE_RFS_PUBLISH, ctx)
|
||
|
||
notice = EmailNotice.objects.create(
|
||
subject=subject,
|
||
body=body,
|
||
to_emails=to_list,
|
||
cc_emails=[],
|
||
bcc_emails=[],
|
||
attachments=[],
|
||
biz_type="inquiry",
|
||
biz_id=inquiry.inquiry_no,
|
||
status="pending",
|
||
payload={
|
||
"template_key": TEMPLATE_RFS_PUBLISH,
|
||
"is_html": True,
|
||
"inquiry_no": inquiry.inquiry_no,
|
||
"inquiry_title": inquiry.title,
|
||
"quote_deadline": ctx.get("deadline_time"),
|
||
"supplier_name": supplier_name,
|
||
},
|
||
)
|
||
|
||
if not to_list:
|
||
notice.status = "failed"
|
||
notice.last_error = "缺少供应商邮箱"
|
||
notice.save(update_fields=["status", "last_error", "update_datetime"])
|
||
continue
|
||
|
||
notice.status = "sending"
|
||
notice.save(update_fields=["status", "update_datetime"])
|
||
|
||
success, detail = send_email_notice(notice)
|
||
notice.response = detail or {}
|
||
if success:
|
||
notice.status = "success"
|
||
notice.sent_at = timezone.now()
|
||
notice.last_error = None
|
||
else:
|
||
notice.status = "failed"
|
||
notice.last_error = detail.get("error") if isinstance(detail, dict) else str(detail)
|
||
|
||
notice.save(update_fields=["status", "sent_at", "response", "last_error", "update_datetime"])
|
||
|
||
|
||
class InquirySupplierViewSet(CustomModelViewSet):
|
||
queryset = InquirySupplier.objects.all()
|
||
serializer_class = InquirySupplierSerializer
|
||
filter_fields = ("inquiry_no", "supplier_code", "supplier_name")
|
||
search_fields = ("inquiry_no", "supplier_code", "supplier_name", "contact_person")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryAttachmentViewSet(CustomModelViewSet):
|
||
queryset = InquiryAttachment.objects.all()
|
||
serializer_class = InquiryAttachmentSerializer
|
||
filter_fields = ("inquiry_no", "file_type", "file_name")
|
||
search_fields = ("inquiry_no", "file_name", "file_type")
|
||
ordering = ("-upload_time",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryMaterialCostViewSet(CustomModelViewSet):
|
||
queryset = InquiryMaterialCost.objects.all()
|
||
serializer_class = InquiryMaterialCostSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "material_spec")
|
||
search_fields = ("inquiry_no", "part_id", "material_spec", "remark")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryProcessCostViewSet(CustomModelViewSet):
|
||
queryset = InquiryProcessCost.objects.all()
|
||
serializer_class = InquiryProcessCostSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "process_station")
|
||
search_fields = ("inquiry_no", "part_id", "process_station", "remark")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryOtherCostViewSet(CustomModelViewSet):
|
||
queryset = InquiryOtherCost.objects.all()
|
||
serializer_class = InquiryOtherCostSerializer
|
||
filter_fields = ("inquiry_no", "part_id")
|
||
search_fields = ("inquiry_no", "part_id")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryProfitCostViewSet(CustomModelViewSet):
|
||
queryset = InquiryProfitCost.objects.all()
|
||
serializer_class = InquiryProfitCostSerializer
|
||
filter_fields = ("inquiry_no", "part_id")
|
||
search_fields = ("inquiry_no", "part_id")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class InquiryRfqItemViewSet(CustomModelViewSet):
|
||
queryset = InquiryRfqItem.objects.all()
|
||
serializer_class = InquiryRfqItemSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "product_name")
|
||
search_fields = ("inquiry_no", "part_id", "product_name")
|
||
ordering = ("id",)
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class MiscLowPriceHeaderViewSet(CustomModelViewSet):
|
||
"""比价-制程最低价记录主表"""
|
||
|
||
queryset = MiscLowPriceHeader.objects.all()
|
||
serializer_class = MiscLowPriceHeaderSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "souce_no", "cost_type", "item_no")
|
||
search_fields = ("inquiry_no", "part_id", "souce_no", "item_no")
|
||
ordering = ("-create_datetime", "id")
|
||
|
||
|
||
class MiscLowPriceDetailViewSet(CustomModelViewSet):
|
||
"""比价-制程最低价记录次表"""
|
||
|
||
queryset = MiscLowPriceDetail.objects.all()
|
||
serializer_class = MiscLowPriceDetailSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "cost_type", "material_spec", "item_no", "souce_no")
|
||
search_fields = ("inquiry_no", "part_id", "material_spec", "item_no", "souce_no")
|
||
ordering = ("-create_datetime", "id")
|
||
|
||
|
||
class MiscNegotiationRecordsViewSet(CustomModelViewSet):
|
||
"""杂采议价记录表"""
|
||
|
||
queryset = MiscNegotiationRecords.objects.all()
|
||
serializer_class = MiscNegotiationRecordsSerializer
|
||
filter_fields = ("inquiry_no", "part_id", "supplier_code")
|
||
search_fields = ("inquiry_no", "part_id", "supplier_code")
|
||
ordering = ("-create_datetime", "id")
|
||
|
||
|
||
class RFQOperationLogsViewSet(CustomModelViewSet):
|
||
"""询价单操作日志(rfq_operation_logs)"""
|
||
|
||
queryset = RFQOperationLogs.objects.all()
|
||
serializer_class = RFQOperationLogsSerializer
|
||
create_serializer_class = RFQOperationLogsSerializer
|
||
update_serializer_class = RFQOperationLogsSerializer
|
||
filter_fields = (
|
||
"quotation_no",
|
||
"operation_type",
|
||
"operation_user",
|
||
"purchase_type",
|
||
)
|
||
search_fields = ("inquiry_no", "quotation_no", "operation_user", "operation_desc")
|
||
ordering = ("-create_datetime", "-id")
|
||
|
||
def filter_queryset(self, queryset):
|
||
"""询价单号模糊查询、按询价主表采购负责人筛选(GET 参数 ``inquiry_no``、``buyer``)。"""
|
||
queryset = super().filter_queryset(queryset)
|
||
inquiry_no = (self.request.query_params.get("inquiry_no") or "").strip()
|
||
if inquiry_no:
|
||
queryset = queryset.filter(inquiry_no__icontains=inquiry_no)
|
||
buyer = (self.request.query_params.get("buyer") or "").strip()
|
||
if buyer:
|
||
nos = Inquiry.objects.filter(buyer__icontains=buyer).values_list("inquiry_no", flat=True)
|
||
queryset = queryset.filter(inquiry_no__in=list(nos))
|
||
return queryset
|
||
|
||
|
||
class MiscProcMaterialMinPricesViewSet(CustomModelViewSet):
|
||
"""杂采材料制程最低价信息表视图集
|
||
|
||
按采购件料号、材料规格、货币代码、项次存储最低重量(1)和最低单价(2)信息。
|
||
"""
|
||
|
||
queryset = MiscProcMaterialMinPrices.objects.all()
|
||
serializer_class = MiscProcMaterialMinPricesSerializer
|
||
filter_fields = (
|
||
"part_id",
|
||
"material_spec",
|
||
"currency_code",
|
||
"item_num",
|
||
)
|
||
search_fields = (
|
||
"part_id",
|
||
"material_spec",
|
||
"source_number",
|
||
)
|
||
ordering = ("-create_datetime", "id")
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save()
|
||
|
||
|
||
class MiscProcProcessingMinPricesViewSet(CustomModelViewSet):
|
||
"""杂采加工费用最低价信息表视图集
|
||
|
||
按采购件料号、货币代码存储各报价单加工费合计的最低值。
|
||
"""
|
||
|
||
queryset = MiscProcProcessingMinPrices.objects.all()
|
||
serializer_class = MiscProcProcessingMinPricesSerializer
|
||
filter_fields = (
|
||
"part_id",
|
||
"currency_code",
|
||
)
|
||
search_fields = (
|
||
"part_id",
|
||
"source_number",
|
||
)
|
||
ordering = ("-create_datetime", "id")
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save()
|
||
|
||
def perform_update(self, serializer):
|
||
serializer.save() |