wuzilong 1 month ago
parent
commit
83192719b0
31 changed files with 186 additions and 25 deletions
  1. 1 0
      backend/app.pid
  2. 30 3
      backend/app/crud/crm/contract.py
  3. 0 0
      backend/graphify-out/cache/ast/0d0c1f9da644cf00f18d3a8777017c5d3a85208cc388f04e6585a7097cdbf717.json
  4. 0 0
      backend/graphify-out/cache/ast/0d996b96f829717dcdaff0fc9ea5a0de858197b1b2260a9512b0e8e6952f88ac.json
  5. 0 0
      backend/graphify-out/cache/ast/15d120bfc61c75f1cd9a9d3cfd6e458de2c1cad5bd861875b0cde14cf3fa70bb.json
  6. 0 0
      backend/graphify-out/cache/ast/40ca3a264b28b560e6e37b8da1c28913538db8866912bc17dfcd3b7a00ebc56b.json
  7. 0 0
      backend/graphify-out/cache/ast/4e9bb78d9ee87d5acddf53b79f438fac25d847c504d3573fbc4076fd7f0f4bfd.json
  8. 0 0
      backend/graphify-out/cache/ast/507078fa10960c7f58ab0358cd737fd0f18f076a7919bc0fa6e1bee66d0b8f6b.json
  9. 0 0
      backend/graphify-out/cache/ast/8dd0b94639eb3080a2abd5cc42f7d542765875a9e1c5760098d5d0438489ce29.json
  10. 0 0
      backend/graphify-out/cache/ast/aa49cd2e2f6c3685baf52b2f818f723b55997aeeb9349e8860ce1e6fb155b97a.json
  11. 0 0
      backend/graphify-out/cache/ast/fd79583de284a2592448a10596ff913884c59acfd15bd1b59f135b0969ce122f.json
  12. 87 0
      backend/tests/test_crm_framework_contract_amount.py
  13. 33 0
      docs/superpowers/plans/2026-09-10-framework-contract-amount-sum.md
  14. 22 0
      docs/superpowers/specs/2026-09-10-framework-contract-amount-sum-design.md
  15. 0 0
      web-admin/graphify-out/cache/ast/041f9f529ee860951afbf51034af2a1673967c221a78c260cdcb31243956037a.json
  16. 0 0
      web-admin/graphify-out/cache/ast/203adc06a6c0250198f44f02624e0f422b8b0f1b8c433a271464963aabf75f66.json
  17. 0 0
      web-admin/graphify-out/cache/ast/3522eb77f1e9b2b06ae221e7181ba27eeb97082f6f892356dec1419fa5048e88.json
  18. 1 0
      web-admin/graphify-out/cache/ast/4645ed9443c7f5ffd71dab5146f77308fb9c23af4c07a41a828d45b492abf029.json
  19. 0 0
      web-admin/graphify-out/cache/ast/57eff7fa0f37a95d61ad9752d137a722836d627f9eabbbc52c8b4675bdc066a8.json
  20. 0 0
      web-admin/graphify-out/cache/ast/750f838bf6a32a9555ab7ce8d1448fdd27c573ba40a5c49edc8363b0f8359d32.json
  21. 0 0
      web-admin/graphify-out/cache/ast/7647b8cca3dd7a8cdf489e844dafe2c3edc80df505d43c33ba9224cf02ad1f72.json
  22. 0 0
      web-admin/graphify-out/cache/ast/88fa2676b8dc822dc1687f462ef989ab392aae9c0e5d0ef438733d7bd8f455ab.json
  23. 0 0
      web-admin/graphify-out/cache/ast/9846fbe4053ab0920a61b3bc7f18f9cd3660e378d9d54331f6d9e01360e0b97f.json
  24. 0 0
      web-admin/graphify-out/cache/ast/a7a01a1d8a8c41fea0a153657ba13ce771e291f31116d1a8b2985d23c3bc6d88.json
  25. 0 0
      web-admin/graphify-out/cache/ast/b1051440f84059b75cece4bb15c23b993c9d79031d34302742886211b2ea128f.json
  26. 0 0
      web-admin/graphify-out/cache/ast/b1208f47d64cd029c663a17bd7989956064ab0410403af89d228320c3c76747d.json
  27. 0 0
      web-admin/graphify-out/cache/ast/cafbd35165a8e07ee98a61f5daa22c55e7e3ba6d3f0ab71c2ba1ec8f3602ceee.json
  28. 0 0
      web-admin/graphify-out/cache/ast/ceeab494be78ecb0f57e76ed185326af662b0138f83593588e4f6964643915f7.json
  29. 0 0
      web-admin/graphify-out/cache/ast/e0b74f9022607cdcccb612c8189c8e13e7a76beacbf3547091775781064ca03d.json
  30. 6 11
      web-admin/src/views/crm/contract/components/ContractFormDialog.vue
  31. 6 11
      web-admin/src/views/crm/contract/form.vue

+ 1 - 0
backend/app.pid

@@ -0,0 +1 @@
+24265

+ 30 - 3
backend/app/crud/crm/contract.py

@@ -1003,11 +1003,17 @@ def _validate_contract_type_and_products(data: dict) -> None:
 
 
 def _normalize_framework_contract_data(data: dict) -> None:
+    """框架合同:按需/随行就市行清零;有小计则累加写入合同金额(不再强制头金额为 0)。"""
     if not is_framework_contract_type(_get_field(data, "contract_type", "contractType")):
         return
-    data["amount"] = 0
-    data["amountWithTax"] = 0
-    products = _get_field(data, "products", "productList") or []
+    products = _get_field(data, "products", "productList")
+    if products is None:
+        # 未带产品明细时不改写金额(避免更新接口误清空)
+        return
+    if not isinstance(products, list):
+        products = []
+
+    total = Decimal("0")
     for product in products:
         if not isinstance(product, dict):
             continue
@@ -1022,6 +1028,20 @@ def _normalize_framework_contract_data(data: dict) -> None:
             product["subtotal"] = 0
             product["taxAmount"] = 0
 
+        subtotal_raw = _get_field(product, "subtotal")
+        if subtotal_raw is not None and str(subtotal_raw).strip() != "":
+            line = _decimal_value(subtotal_raw)
+        else:
+            qty = _decimal_value(_get_field(product, "quantity"))
+            price = _decimal_value(_get_field(product, "price"))
+            discount = _decimal_value(_get_field(product, "discount"))
+            line = qty * price * (Decimal("1") - discount / Decimal("100"))
+        total += line
+
+    tax_rate = _decimal_value(_get_field(data, "tax_rate", "taxRate"), Decimal("0"))
+    data["amount"] = float(total)
+    data["amountWithTax"] = float(_calc_amount_with_tax(total, tax_rate))
+
 
 def _validate_product_prices_by_contract_type(data: dict) -> None:
     contract_type = (_get_field(data, "contract_type", "contractType") or "").strip()
@@ -1400,6 +1420,13 @@ def update_contract(
     contract_type = (_get_field(data, "contract_type", "contractType") or old.contract_type or "sale").strip()
     old.contract_type = contract_type
 
+    # 框架合同金额在 work_data 上汇总后,需写回 data,供下方 apply 落库
+    if is_framework_contract_type(contract_type) and product_rows is not None:
+        if "amount" in work_data:
+            data["amount"] = work_data["amount"]
+        if "amountWithTax" in work_data:
+            data["amountWithTax"] = work_data["amountWithTax"]
+
     # 再编辑回草稿:作废全部历史审批(含已通过/已拒绝),避免再提审后 canApprove 命中旧记录
     invalidate_all_approvals(db, contract_id)
 

File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/0d0c1f9da644cf00f18d3a8777017c5d3a85208cc388f04e6585a7097cdbf717.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/0d996b96f829717dcdaff0fc9ea5a0de858197b1b2260a9512b0e8e6952f88ac.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/15d120bfc61c75f1cd9a9d3cfd6e458de2c1cad5bd861875b0cde14cf3fa70bb.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/40ca3a264b28b560e6e37b8da1c28913538db8866912bc17dfcd3b7a00ebc56b.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/4e9bb78d9ee87d5acddf53b79f438fac25d847c504d3573fbc4076fd7f0f4bfd.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/507078fa10960c7f58ab0358cd737fd0f18f076a7919bc0fa6e1bee66d0b8f6b.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/8dd0b94639eb3080a2abd5cc42f7d542765875a9e1c5760098d5d0438489ce29.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/aa49cd2e2f6c3685baf52b2f818f723b55997aeeb9349e8860ce1e6fb155b97a.json


File diff suppressed because it is too large
+ 0 - 0
backend/graphify-out/cache/ast/fd79583de284a2592448a10596ff913884c59acfd15bd1b59f135b0969ce122f.json


+ 87 - 0
backend/tests/test_crm_framework_contract_amount.py

@@ -0,0 +1,87 @@
+"""框架合同金额:有小计则累加,不再强制为 0。"""
+from decimal import Decimal
+
+from app.crud.crm.contract import _normalize_framework_contract_data
+
+
+def test_framework_sale_sums_fixed_line_subtotals():
+    data = {
+        "contractType": "framework_sale",
+        "taxRate": 6,
+        "products": [
+            {
+                "quantityMode": "fixed",
+                "priceMode": "fixed",
+                "quantity": 10,
+                "price": 100,
+                "subtotal": 1000,
+            },
+            {
+                "quantityMode": "fixed",
+                "priceMode": "fixed",
+                "quantity": 2,
+                "price": 50,
+                "subtotal": 100,
+            },
+        ],
+    }
+    _normalize_framework_contract_data(data)
+    assert Decimal(str(data["amount"])) == Decimal("1100")
+    assert Decimal(str(data["amountWithTax"])) == Decimal("1166")
+
+
+def test_framework_purchase_mixed_market_rows_contribute_zero():
+    data = {
+        "contract_type": "framework_purchase",
+        "tax_rate": 6,
+        "productList": [
+            {
+                "quantity_mode": "fixed",
+                "price_mode": "fixed",
+                "quantity": 5,
+                "price": 200,
+                "subtotal": 1000,
+            },
+            {
+                "quantity_mode": "customer_demand",
+                "price_mode": "market",
+                "quantity": 99,
+                "price": 999,
+                "subtotal": 9999,
+            },
+        ],
+    }
+    _normalize_framework_contract_data(data)
+    market = data["productList"][1]
+    assert market["quantity"] == 0
+    assert market["price"] == 0
+    assert market["subtotal"] == 0
+    assert Decimal(str(data["amount"])) == Decimal("1000")
+
+
+def test_framework_all_market_amount_is_zero():
+    data = {
+        "contractType": "framework",
+        "products": [
+            {
+                "quantityMode": "customer_demand",
+                "priceMode": "market",
+                "quantity": 1,
+                "price": 10,
+                "subtotal": 10,
+            }
+        ],
+    }
+    _normalize_framework_contract_data(data)
+    assert Decimal(str(data["amount"])) == Decimal("0")
+    assert Decimal(str(data["amountWithTax"])) == Decimal("0")
+
+
+def test_non_framework_unchanged():
+    data = {
+        "contractType": "sale",
+        "amount": 500,
+        "products": [{"quantity": 1, "price": 500, "subtotal": 500}],
+    }
+    _normalize_framework_contract_data(data)
+    assert data["amount"] == 500

+ 33 - 0
docs/superpowers/plans/2026-09-10-framework-contract-amount-sum.md

@@ -0,0 +1,33 @@
+# 框架合同金额按产品小计汇总 — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 框架销售/采购合同在新建或保存时,按产品行小计汇总合同金额(随行就市/按需行贡献 0)。
+
+**Architecture:** 后端 `_normalize_framework_contract_data` 去掉头金额强制 0,改为累加行小计;更新接口将规范化后的 amount 写回 apply 所用 payload。前端去掉 `amount = 0` 短路,统一 `calculateTotalAmount`。交付生产备份+回填 SQL。
+
+**Tech Stack:** FastAPI / pytest;Vue 3 web-admin
+
+---
+
+### Task 1: 后端单测 + 实现
+
+**Files:**
+- Create: `backend/tests/test_crm_framework_contract_amount.py`
+- Modify: `backend/app/crud/crm/contract.py`
+
+- [x] 写失败用例(固定价合计、混用、全 market)
+- [x] 改 `_normalize_framework_contract_data` 并同步 update 路径 amount
+- [x] 跑通 pytest
+
+### Task 2: 前端
+
+**Files:**
+- Modify: `web-admin/src/views/crm/contract/components/ContractFormDialog.vue`
+- Modify: `web-admin/src/views/crm/contract/form.vue`
+
+- [x] `calculateTotalAmount` / 切类型 / 载入产品:框架合同改为累加
+
+### Task 3: 生产 SQL
+
+- [x] 输出备份表 + 按 `crm_contract_product.subtotal` 回填框架合同金额的语句

+ 22 - 0
docs/superpowers/specs/2026-09-10-framework-contract-amount-sum-design.md

@@ -0,0 +1,22 @@
+# 框架合同金额按产品小计汇总
+
+## 背景
+
+框架销售/采购合同(`framework_sale` / `framework_purchase` / 历史 `framework`)在前后端均强制将合同头 `amount` / `amountWithTax` 置为 0,导致产品行已有固定数量与单价时,列表与详情仍显示合同金额为 0。
+
+## 需求
+
+1. 有小计就累加:各行 `subtotal`(或 数量×单价×折扣)之和写入合同金额;`customer_demand` / `market` 行贡献 0。
+2. 运行时仅新建/再次保存生效;不做应用内批量迁移。
+3. 代码完成后提供生产库「先备份、再回填」SQL,由人工执行。
+
+## 设计
+
+- 后端 `_normalize_framework_contract_data`:保留行级 market/demand 清零;删除头金额强制 0;规范化后按行累加写回 `amount`(及 `amountWithTax` 与现有销售合同口径一致)。
+- 前端 `calculateTotalAmount` 及切类型/载入产品处:去掉框架合同 `amount = 0`,与销售合同相同累加。
+- 更新路径保证规范化后的 `amount` 写入实体(`update_contract` 当前对 `work_data` 规范化但对 `data` 做 apply,需同步金额字段)。
+- 测试覆盖:全固定价、混用、全随行就市。
+
+## 非目标
+
+不改审批流、调价区间、计划超量提醒;不在启动时跑迁移。

File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/041f9f529ee860951afbf51034af2a1673967c221a78c260cdcb31243956037a.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/203adc06a6c0250198f44f02624e0f422b8b0f1b8c433a271464963aabf75f66.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/3522eb77f1e9b2b06ae221e7181ba27eeb97082f6f892356dec1419fa5048e88.json


+ 1 - 0
web-admin/graphify-out/cache/ast/4645ed9443c7f5ffd71dab5146f77308fb9c23af4c07a41a828d45b492abf029.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_crm_col_filter_header_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/crm-col-filter-header/index.vue", "source_location": "L1"}, {"id": "crm_col_filter_header_index_options", "label": "options", "file_type": "code", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/crm-col-filter-header/index.vue", "source_location": "L74"}], "edges": [{"source": "users_zlwu_work_00code_shanxia2_0_web_admin_src_components_business_crm_col_filter_header_index_vue", "target": "crm_col_filter_header_index_options", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/crm-col-filter-header/index.vue", "source_location": "L74", "weight": 1.0}], "raw_calls": []}

File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/57eff7fa0f37a95d61ad9752d137a722836d627f9eabbbc52c8b4675bdc066a8.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/750f838bf6a32a9555ab7ce8d1448fdd27c573ba40a5c49edc8363b0f8359d32.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/7647b8cca3dd7a8cdf489e844dafe2c3edc80df505d43c33ba9224cf02ad1f72.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/88fa2676b8dc822dc1687f462ef989ab392aae9c0e5d0ef438733d7bd8f455ab.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/9846fbe4053ab0920a61b3bc7f18f9cd3660e378d9d54331f6d9e01360e0b97f.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/a7a01a1d8a8c41fea0a153657ba13ce771e291f31116d1a8b2985d23c3bc6d88.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/b1051440f84059b75cece4bb15c23b993c9d79031d34302742886211b2ea128f.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/b1208f47d64cd029c663a17bd7989956064ab0410403af89d228320c3c76747d.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/cafbd35165a8e07ee98a61f5daa22c55e7e3ba6d3f0ab71c2ba1ec8f3602ceee.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/ceeab494be78ecb0f57e76ed185326af662b0138f83593588e4f6964643915f7.json


File diff suppressed because it is too large
+ 0 - 0
web-admin/graphify-out/cache/ast/e0b74f9022607cdcccb612c8189c8e13e7a76beacbf3547091775781064ca03d.json


+ 6 - 11
web-admin/src/views/crm/contract/components/ContractFormDialog.vue

@@ -1518,11 +1518,7 @@ const applyContractProductsToForm = async (products: ExtendedContractProductForm
       calculateProductAmount(product)
     }
   })
-  if (isFw) {
-    form.value.amount = 0
-  } else {
-    calculateTotalAmount()
-  }
+  calculateTotalAmount()
 }
 
 /** 复制合同:仅带入产品明细,客户等信息由用户重新填写 */
@@ -2323,13 +2319,16 @@ const handleSwapParties = async () => {
 /** 合同类型变化处理 */
 const handleContractTypeChange = (value: string) => {
   if (isFrameworkContractType(value)) {
-    form.value.amount = 0
     form.value.products?.forEach((p) => {
       applyFrameworkDefaultsToProduct(p)
       if (!p.priceTiers) {
         p.priceTiers = []
       }
+      if (!isFrameworkPriceMarket(p)) {
+        calculateProductAmount(p)
+      }
     })
+    calculateTotalAmount()
   } else {
     form.value.products?.forEach((p) => {
       normalizeFrameworkProductPricing(p, false)
@@ -2618,12 +2617,8 @@ const calculateProductAmount = (product: ExtendedContractProductForm) => {
   product.taxAmount = taxAmount
 }
 
-/** 计算合计金额 */
+/** 计算合计金额(含框架合同:按需/随行就市行为 0,有小计则累加) */
 const calculateTotalAmount = () => {
-  if (isFrameworkContractType(form.value.contractType)) {
-    form.value.amount = 0
-    return
-  }
   let total = 0
   form.value.products.forEach((item) => {
     const quantity = item.quantity || 0

+ 6 - 11
web-admin/src/views/crm/contract/form.vue

@@ -841,11 +841,7 @@ const loadContractDetail = async (id: number | string) => {
         calculateProductAmount(product);
       }
     });
-    if (isFw) {
-      form.value.amount = 0;
-    } else {
-      calculateTotalAmount();
-    }
+    calculateTotalAmount();
   }
 
   // 6. 设置合同期限日期范围
@@ -1389,13 +1385,16 @@ const handleSwapParties = async () => {
 /** 合同类型变化处理 */
 const handleContractTypeChange = (value: string) => {
   if (isFrameworkContractType(value)) {
-    form.value.amount = 0;
     form.value.products?.forEach((p) => {
       applyFrameworkDefaultsToProduct(p);
       if (!p.priceTiers) {
         p.priceTiers = [];
       }
+      if (!isFrameworkPriceMarket(p)) {
+        calculateProductAmount(p);
+      }
     });
+    calculateTotalAmount();
   } else {
     form.value.products?.forEach((p) => {
       normalizeFrameworkProductPricing(p, false);
@@ -1641,12 +1640,8 @@ const calculateProductAmount = (product: ExtendedContractProductForm) => {
   product.taxAmount = taxAmount;
 };
 
-/** 计算合计金额 */
+/** 计算合计金额(含框架合同:按需/随行就市行为 0,有小计则累加) */
 const calculateTotalAmount = () => {
-  if (isFrameworkContractType(form.value.contractType)) {
-    form.value.amount = 0;
-    return;
-  }
   let total = 0;
   form.value.products.forEach((item) => {
     const quantity = item.quantity || 0;

Some files were not shown because too many files changed in this diff