From 0424a3e3f405e888eee771f0e30260f8ad8ba9fb Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Wed, 1 Apr 2026 11:02:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AF=A2=E4=BB=B7=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=B1=A5=E5=8E=86=E8=A1=A8=E6=9B=B4=E6=96=B0=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=9B=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AFbug--?= =?UTF-8?q?=E6=8A=A5=E4=BB=B7=E5=8D=95=E4=BB=85=E6=8B=89=E8=B5=B710?= =?UTF-8?q?=E6=9D=A1=E6=95=B0=E6=8D=AE=EF=BC=9B=E6=9D=82=E9=87=87=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E7=9B=B8=E5=BA=94=E8=A1=A8=E7=BB=93=E6=9E=84=E5=90=8D?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E7=BC=80pis=5Fmisc=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/models.py | 8 +- .../pisadmin/miscprocurement/serializers.py | 6 +- .../apps/pisadmin/miscprocurement/views.py | 11 ++ backend/apps/pissupplier/models.py | 14 +-- backend/apps/pissupplier/views.py | 24 +++- web/src/views/pissupplier/quotation/api.ts | 2 + web/src/views/pissupplier/quotation/crud.tsx | 113 ++++++++++-------- 7 files changed, 117 insertions(+), 61 deletions(-) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 2551408..d1eb6f9 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -367,6 +367,12 @@ class Inquiry(CoreModel): inq.update_user = username inq.save(update_fields=["status", "update_time", "update_user", "update_datetime"]) qn = (quotation_no or "-").strip()[:20] or "-" + qm_submit = ( + QuotationMaster.objects.filter(inquiry_no=inq_no, quotation_no=qn).first() + if qn != "-" + else None + ) + submitter_name = (getattr(qm_submit, "supplier_name", None) or "").strip() or "—" RFQOperationLogs.try_append( inquiry_no=inq.inquiry_no, purchase_type=int(inq.purchase_type), @@ -375,7 +381,7 @@ class Inquiry(CoreModel): quotation_no=qn, per_status=old_status, cur_status=5, - operation_desc="全部供应商已提交报价,询价单同步为报价结束", + operation_desc=f"供应商({submitter_name})提交报价,受邀供应商均提交报价,询价单报价结束", ) try: from apps.pisadmin.basicinfo.views.email_utils import send_quote_ended_notice_to_purchaser diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 35de75c..7a5084a 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -316,7 +316,11 @@ class MiscNegotiationRecordsSerializer(CustomModelSerializer): class RFQOperationLogsSerializer(CustomModelSerializer): - """询价单操作日志(rfq_operation_logs)""" + """询价单操作日志(rfq_operation_logs)。 + + 列表查询:``GET /api/.../miscprocurement/inquiry/{id}/operation_logs/``(按询价单主键, + 返回该单 ``inquiry_no`` 下全部日志,时间倒序)。 + """ operation_type = serializers.IntegerField(required=True) operation_time = serializers.DateTimeField( diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 030c9ce..4ce8136 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1215,6 +1215,17 @@ class InquiryViewSet(CustomModelViewSet): 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=["put"], detail=True, url_path="save_negotiation_records") def save_negotiation_records(self, request, pk=None): """按报价单写入杂采议价记录:议价结果 + 该报价单议价前含税/不含税总价快照(来自上阶物料明细)。""" diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 3227b28..0795918 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -68,7 +68,7 @@ class QuotationMaster(models.Model): bid_end_time = models.DateTimeField(null=True, blank=True, verbose_name="投标截止时间") class Meta: - db_table = table_prefix + "sup_quotation_master" + db_table = table_prefix + "misc_sup_quotation_master" verbose_name = "杂采报价单主表" verbose_name_plural = verbose_name ordering = ("-creattime", "-autoid") @@ -96,7 +96,7 @@ class QuotationAttachment(models.Model): uploaduser = models.CharField(max_length=20, null=True, blank=True, verbose_name="上传人员") class Meta: - db_table = table_prefix + "sup_quotation_attachment" + db_table = table_prefix + "misc_sup_quotation_attachment" verbose_name = "杂采报价单-附件关联表" verbose_name_plural = verbose_name ordering = ("-autoid",) @@ -159,7 +159,7 @@ class QuotationMaterial(models.Model): option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") class Meta: - db_table = table_prefix + "sup_quotation_material" + db_table = table_prefix + "misc_sup_quotation_material" verbose_name = "杂采报价单-材料成本明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -221,7 +221,7 @@ class QuotationProcess(models.Model): option_json = models.TextField(db_column="OptionJson", null=True, blank=True, verbose_name="可选扩展信息") class Meta: - db_table = table_prefix + "sup_quotation_process" + db_table = table_prefix + "misc_sup_quotation_process" verbose_name = "杂采报价单-加工成本明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -265,7 +265,7 @@ class QuotationOther(models.Model): ) class Meta: - db_table = table_prefix + "sup_quotation_other" + db_table = table_prefix + "misc_sup_quotation_other" verbose_name = "杂采报价单-其他费用明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -308,7 +308,7 @@ class QuotationProfit(models.Model): ) class Meta: - db_table = table_prefix + "sup_quotation_profit" + db_table = table_prefix + "misc_sup_quotation_profit" verbose_name = "杂采报价单-税率利润明细表" verbose_name_plural = verbose_name ordering = ("autoid",) @@ -439,7 +439,7 @@ class QuotationItem(models.Model): ) class Meta: - db_table = table_prefix + "sup_quot_items" + db_table = table_prefix + "misc_sup_quot_items" verbose_name = "杂采报价单-上阶物料明细表" verbose_name_plural = verbose_name ordering = ("autoid",) diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index ecfda42..592e7c2 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -201,7 +201,15 @@ class QuotationMasterViewSet(CustomModelViewSet): @action(methods=["post"], detail=True, url_path="submit") def submit(self, request, pk=None): - """正式提交报价:写入当前时间为报价时间,状态为已报价(3)。仅报价中(status=2)可提交。""" + """正式提交报价:写入当前时间为报价时间,状态为已报价(3)。仅报价中(status=2)可提交。 + + 若本次提交后,询价单下受邀供应商均已「已报价」,则 ``Inquiry.sync_to_quote_closed_when_all_suppliers_quoted`` + 将询价单置为「报价结束」,并由该同步逻辑向采购负责人发送 HTML 邮件(模板 ``Quote_ended``), + 不在本 action 内重复发信。 + + 若本次提交**未**触发询价单收口为「报价结束」,则单独写一条操作日志(描述「{供应商名称}供应商提交报价」、询价单前后状态均为报价中); + 若已收口,则仅由 ``sync_to_quote_closed_when_all_suppliers_quoted`` 写一条合并描述(含提交与报价结束),本处不再重复记日志。 + """ instance = self.get_object() if instance.status != 2: return ErrorResponse(msg="仅报价中状态可提交报价") @@ -224,6 +232,20 @@ class QuotationMasterViewSet(CustomModelViewSet): actor_username=username, quotation_no=getattr(instance, "quotation_no", None), ) + if not inquiry_quote_closed: + inq = Inquiry.objects.filter(inquiry_no=instance.inquiry_no).only("purchase_type").first() + purchase_type = int(inq.purchase_type) if inq else 2 + supplier_name = (getattr(instance, "supplier_name", None) or "").strip() or "—" + RFQOperationLogs.try_append( + inquiry_no=instance.inquiry_no, + purchase_type=purchase_type, + operation_type=6, + operation_user=username, + quotation_no=getattr(instance, "quotation_no", None), + per_status=4, + cur_status=4, + operation_desc=f"供应商({supplier_name})提交报价", + ) serializer = self.get_serializer(instance) payload = dict(serializer.data) payload["inquiry_quote_closed"] = inquiry_quote_closed diff --git a/web/src/views/pissupplier/quotation/api.ts b/web/src/views/pissupplier/quotation/api.ts index 62e7e48..4356730 100644 --- a/web/src/views/pissupplier/quotation/api.ts +++ b/web/src/views/pissupplier/quotation/api.ts @@ -28,6 +28,8 @@ export type PaymentCode = 1 | 2 | 3 | 4 /** * 列表:GET quotation_master/;详情/更新:pk 对应模型主键 autoid * + * 分页与 dvadmin 一致:查询参数为 `page`、`limit`(`page_size` 无效);默认每页 10 条。 + * * 报价主表(QuotationMaster)仅含 inquiry_no,不含询价名称与询价模板编号;列表展示由 crud 中 * 关联 Inquiry 与成本/价格模板接口补全 title、template 与模板名称。 * diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index edd00d5..fa0cb5f 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -430,7 +430,9 @@ const processFeeKeys = ['processFee', 'processprice', 'process_price', 'process_ const processStationKeys = ['process_station', 'processStation'] /** - * 与后端子表模型字段及 costRowsToNestedPayload 使用的 row.values 键一致(写死列,不随询价模板增减列)。 + * 与后端子表模型字段及 costRowsToNestedPayload 使用的 row.values 键一致。 + * 展示用列名优先来自询价 `template_sections` 各段 `fields[].label`(与 miscInquiryDetail 成本结构一致), + * 本常量仅在模板未配置字段时作列键与默认标题的 fallback。 * QuotationMaterial / QuotationProcess / QuotationOther / QuotationProfit 见 apps.pissupplier.models */ export type QuotationCostColumn = { @@ -531,52 +533,7 @@ const TEMPLATE_KEY_TO_UI_KEYS: Record> = { const normTplKey = (k: string) => String(k || '').toLowerCase().replace(/[^a-z0-9]/g, '') -/** 固定列 key 集合(用于判断模板字段是否已映射到内置列,避免重复) */ -const fixedQuotationSectionKeys = (section: string) => - new Set((FIXED_QUOTATION_SECTION_COLUMNS[section] || []).map((c) => c.key)) - -/** - * 在固定报价列基础上,仅按成本模板 `template_sections` 追加「扩展」列(如自定义 item_no),不展开整段 JSON。 - * 仅处理材料/加工(与 option_json 扩展落库一致);内置字段若已映射到固定列则跳过。 - */ -const mergeQuotationSectionColumns = (section: string, templateSections: any): QuotationCostColumn[] => { - const base = [...(FIXED_QUOTATION_SECTION_COLUMNS[section] || [])] - if (section !== '材料成本' && section !== '加工成本') return base - - const tpl = normalizeSections(templateSections).find( - (s: any) => (s.title || s.name || s.section || '') === section - ) - const fixedKeys = fixedQuotationSectionKeys(section) - const keyMap = TEMPLATE_KEY_TO_UI_KEYS[section] || {} - const seen = new Set(base.map((c) => c.key)) - const extras: QuotationCostColumn[] = [] - - for (const f of tpl?.fields || []) { - const rawKey = String(f?.key || '').trim() - if (!rawKey) continue - const nk = normTplKey(rawKey) - if (nk === 'partid' || rawKey === 'part_id') continue - - const uiKeys: string[] = keyMap[nk] || keyMap[String(f.key).toLowerCase()] || [rawKey] - const mapsToFixed = uiKeys.some((k) => fixedKeys.has(k)) - if (mapsToFixed) continue - if (fixedKeys.has(rawKey)) continue - if (seen.has(rawKey)) continue - seen.add(rawKey) - const label = String(f.label || f.nameCn || f.name || rawKey).trim() || rawKey - extras.push({ key: rawKey, label }) - } - - if (!extras.length) return base - const remarkIdx = base.findIndex((c) => c.key === 'remark') - if (remarkIdx >= 0) { - const remarkCol = base[remarkIdx] - const head = base.filter((c) => c.key !== 'remark') - return [...head, ...extras, remarkCol] - } - return [...base, ...extras] -} - +/** 模板未声明某固定列时的列标题兜底(与 merge 补列一致) */ const labelFallbacks: Record = { material: '材质', material_cost: '材料费用', @@ -612,7 +569,58 @@ const labelFallbacks: Record = { fee: '加工费', process_fee: '加工费', packageFee: '包装费', - transportFee: '运输费' + transportFee: '运输费', + profitRate: '利润率(%)', + taxRate: '税率(%)' +} + +const templateFieldDisplayLabel = (f: any, rawKey: string) => + String(f?.label ?? f?.nameCn ?? f?.name_cn ?? f?.name ?? rawKey).trim() || rawKey + +/** + * 成本结构列:与 miscInquiryDetail 一致,优先按 `template_sections` 各段 `fields` 顺序与中文名展示; + * 模板字段 key 经 TEMPLATE_KEY_TO_UI_KEYS 映射到报价单 UI 存储键;无模板字段时回退 FIXED_QUOTATION_SECTION_COLUMNS。 + */ +const mergeQuotationSectionColumns = (section: string, templateSections: any): QuotationCostColumn[] => { + const fixedFallback = [...(FIXED_QUOTATION_SECTION_COLUMNS[section] || [])] + const tpl = normalizeSections(templateSections).find( + (s: any) => (s.title || s.name || s.section || '') === section + ) + const fields = Array.isArray(tpl?.fields) ? tpl.fields : [] + const keyMap = TEMPLATE_KEY_TO_UI_KEYS[section] || {} + + if (!fields.length) { + return fixedFallback + } + + const out: QuotationCostColumn[] = [] + const seen = new Set() + + for (const f of fields) { + const rawKey = String(f?.key || '').trim() + if (!rawKey) continue + const nk = normTplKey(rawKey) + if (section === '加工成本' && (nk === 'processfee' || rawKey === 'process_fee')) continue + if (nk === 'partid' || rawKey === 'part_id') continue + + const uiKeys: string[] = keyMap[nk] || keyMap[String(f.key).toLowerCase()] || [] + const uiKey = uiKeys.length ? uiKeys[0] : rawKey + if (seen.has(uiKey)) continue + seen.add(uiKey) + out.push({ key: uiKey, label: templateFieldDisplayLabel(f, rawKey) }) + } + + for (const fc of fixedFallback) { + if (!seen.has(fc.key)) { + seen.add(fc.key) + out.push({ + key: fc.key, + label: labelFallbacks[fc.key] || fc.label + }) + } + } + + return out.length ? out : fixedFallback } const costTemplates: Record = { @@ -1639,8 +1647,11 @@ export function useQuoteCrud(options?: { } catch (e) { console.warn('同步已过期报价单状态失败', e) } - const res = await api.getList({ page: 1, page_size: 200 }) - const list = res?.data?.results || res?.data?.data?.results || res?.data?.list || res?.data || [] + /** 与后端 dvadmin CustomPagination 一致:每页条数参数为 `limit`(非 page_size),默认 10 会导致列表只拉取 10 条 */ + const res: any = await api.getList({ page: 1, limit: 999 }) + const list = Array.isArray(res?.data) + ? res.data + : res?.data?.results || res?.data?.list || res?.results || res?.list || [] const mapped = (Array.isArray(list) ? list : []).map(mapBackendQuote) await enrichQuotesWithInquiryData(mapped) quotes.value = mapped @@ -1849,7 +1860,7 @@ export function useQuoteCrud(options?: { const countSection = (sec: string) => costRows.value.filter((r) => r.section === sec).length allowed.forEach((sec) => { if (countSection(sec) > 0) return - const cols = FIXED_QUOTATION_SECTION_COLUMNS[sec] + const cols = mergeQuotationSectionColumns(sec, sections) if (!cols?.length) return const values: Record = {} const labels: Record = {}