From e0e1430e8da2d1633b6fa3058d928d463be621b8 Mon Sep 17 00:00:00 2001 From: seagull Date: Sun, 13 Sep 2026 00:25:50 +0800 Subject: [PATCH] =?UTF-8?q?P2-2=E9=83=A8=E7=BD=B2=E5=B0=9D=E8=AF=95+compos?= =?UTF-8?q?e=E9=80=9A=E9=85=8D=E7=AC=A6=E4=BF=AE=E5=A4=8D=20/=20D2?= =?UTF-8?q?=E6=94=B6=E5=B0=BE(=E5=94=AF=E4=B8=80=E5=8D=95=E5=8F=B7+?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E6=96=AD=E8=A8=80+H5=E5=9B=9E=E5=BD=9217?= =?UTF-8?q?=E8=BF=87)=20/=20P2-1=20StorefrontAdmin=E7=9C=9F=E5=88=86?= =?UTF-8?q?=E9=A1=B5=20/=20=E5=90=8E=E7=AB=AF511+PG4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS_AGI_ITERATION_8.md | 88 ++++++++++++++++++++++++++ backend/apps/storefront/views.py | 20 ++++-- backend/docker-compose.yml | 5 +- backend/scripts/verify_salesman_h5.py | 34 ++++++++-- backend/tests/test_storefront.py | 31 +++++++++ docs/DEPLOY_CHECKLIST.md | 18 +++++- frontend/src/pages/Salesman.vue | 33 ++++------ frontend/src/pages/StorefrontAdmin.vue | 19 +++++- frontend/src/utils/salesman.js | 45 +++++++++++++ frontend/tests/salesman.test.mjs | 59 +++++++++++++++++ 10 files changed, 319 insertions(+), 33 deletions(-) create mode 100644 PROGRESS_AGI_ITERATION_8.md create mode 100644 frontend/src/utils/salesman.js create mode 100644 frontend/tests/salesman.test.mjs diff --git a/PROGRESS_AGI_ITERATION_8.md b/PROGRESS_AGI_ITERATION_8.md new file mode 100644 index 0000000..e8a5c8e --- /dev/null +++ b/PROGRESS_AGI_ITERATION_8.md @@ -0,0 +1,88 @@ +# PROGRESS_AGI_ITERATION_8 · P2-2部署尝试+D2收尾+P2-1分页补齐 + +> 跑法口径:后端 `pytest` 全量(`tests/` + `apps/core/tests`),前端 `npm run build` + `npm test`。 +> - 后端 SQLite:**511 passed / 4 skipped**(4 skipped 均为 PG 专用并发用例) +> - 后端 PG 5433 `-m postgres`:**4 passed** +> - 前端:`npm run build` ✅ 5.76s;`npm test` ✅ 17 pass / 0 fail(新增 salesman 6 用例) +> - `manage.py check` 0 issues;`makemigrations --check` No changes +> - P2-2:`docker compose config` 双栈 exit 0;`build/up/冒烟` 因 daemon 未起 + 无服务器凭证未做,不虚报 +> - P3-1:按规格跳过,未建模(`Serial` grep 零命中即预期) + +## 一、P2-2 部署尝试(NEXT_PLAN §P2-2 按步骤执行,不伪造上线) + +| 步骤 | 结果 | +|---|---| +| `config` 插值校验(`backend/infra/docker/docker-compose.yml`) | ✅ exit 0 | +| `config` 插值校验(`backend/docker-compose.yml`,1Panel 上线栈) | ✅ exit 0(`DJANGO_ALLOWED_HOSTS=example.com` 正确展开,两处 service 均确认) | +| `build backend` | ❌ 未跑:daemon 未运行(`failed to connect to the docker API at npipe://...dockerDesktopLinuxEngine`);首次不带变量构建即暴露插值必填(`DJANGO_SECRET_KEY/POSTGRES_PASSWORD required`),属预期门禁 | +| `up -d` + 冒烟(ping/登录/开单) | ❌ 未做:无 192.168.5.7 服务器凭证,绝不伪造上线 | + +### 附带真缺口修复:上线栈 compose 通配符硬编码 + +`backend/docker-compose.yml` 的 `backend`/`worker` 两处 `DJANGO_ALLOWED_HOSTS: "*"` +硬编码,与 `prod.py` 的通配符门禁(迭代 7 新增)直接冲突 —— 该 compose 一上线 +就会被 prod 拒绝启动。改为 `${DJANGO_ALLOWED_HOSTS:?required}`,与 +`DJANGO_SECRET_KEY`/`POSTGRES_PASSWORD` 同级必填。本地自带 db 栈 +(`backend/infra/docker/docker-compose.yml`)无此问题(未设该变量,走 prod 门禁显式报错)。 +清单 `docs/DEPLOY_CHECKLIST.md` §三/§四已同步更新(含 daemon 实报错 + 上线前必设域名的 gate)。 + +## 二、D2 收尾(verify 脚本 + H5 回归进前端测试) + +1. **`backend/scripts/verify_salesman_h5.py`**:时间戳唯一收款单号(`SMH`, + 可重复跑不撞唯一约束)+ 开单/收款前后 `count` 基线与 `+1` 差值断言(残留隔离, + 不删单 —— 过账扣库存/生成应收/凭证/核销,硬删会炸外键)。`py_compile` 通过; + 真机 Playwright 部分需 dev 后端 :18099 + vite :15173,本轮未重跑(沿用迭代 7 的 + ALL PASSED 证据,脚本逻辑变更仅隔离层,未动 10 步断言本体)。 +2. **H5 回归进 `npm test`**:新建 `frontend/src/utils/salesman.js` 纯函数 + (`rowAmount/linesTotal/lineTaxAmount/taxTotal/roundOffAmount/netTotal/openDebts/debtTotal`, + 抹零与价内税拆分与 D4 同公式),`Salesman.vue` 的 5 处 computed 改调同一份实现, + 消灭两份口径;新增 `frontend/tests/salesman.test.mjs` 6 用例(合计/抹零/税额/欠款过滤)。 + +## 三、P2-1 + 附录A(StorefrontAdmin 分页 + 十项逐项核验) + +- **P2-1 尾巴**:`StorefrontAdmin.vue` 无分页(订单超 20 条吞数据,NEXT_PLAN §P2-1 点名)。 + 后端 `admin_orders` function 视图补真分页(`page/page_size`,`count` 为过滤后总数, + 上限 200,与 `StandardAsyncPagination` 同口径)+ 前端 `el-pagination` 接 `count` + + 新增 `test_admin_orders_pagination`(3 单分 2 页,`count==3` 且两页无重叠)。 + `Channel.vue` 的"最近 50 条"为有意截断展示(标题明示),非分页缺失,不动。 +- **附录A十项逐项核验**(页面存在 + 接真接口 + 路由 + 菜单): + 收款/付款(含 allocate 核销)/核销记录/库存流水/预警规则(含 init-default)/ + 打印模板/自定义报表(含 init-system/run)/库存大盘抽屉(inventory-status)/ + 科目管理(含 init-chart)/价格等级与联系人 —— 十项全部 ✅,无"有接口无页面"残留。 + 路由 29 条,菜单 24 项(含库存流水/预警规则/打印模板/自定义报表/科目/价格等级/核销记录)。 + +## 四、最终验证数字(本轮实跑) + +``` +backend pytest tests/ + apps/core/tests(SQLite): 511 passed / 4 skipped +backend PG -m postgres: 4 passed +backend pytest tests/test_storefront.py: 31 passed(含新增 pagination) +frontend npm test: 17 pass / 0 fail(11 原有 + 6 salesman 新增) +frontend npm run build: ✓ 5.76s(含 Salesman-16.62kB / StorefrontAdmin-8.39kB) +manage.py check: 0 issues;makemigrations --check: No changes +docker compose config(双栈): exit 0 +``` + +## 五、变更文件清单 + +``` +backend/docker-compose.yml [改] ALLOWED_HOSTS "*" 硬编码 → ${...:?required}(两处) +backend/apps/storefront/views.py [改] admin_orders 真分页(page/page_size + count) +backend/tests/test_storefront.py [改] +test_admin_orders_pagination +backend/scripts/verify_salesman_h5.py [改] 唯一收款单号 + 前后 count 差值断言 +frontend/src/utils/salesman.js [新] D2 H5 纯计算(与组件同一份实现) +frontend/tests/salesman.test.mjs [新] 6 用例(合计/抹零/税额/欠款) +frontend/src/pages/Salesman.vue [改] 5 处 computed 改调 salesman.js +frontend/src/pages/StorefrontAdmin.vue [改] 订单 el-pagination + count 接线 +docs/DEPLOY_CHECKLIST.md [改] §三追加 compose 通配符修复 + §四 daemon 实报错 +frontend/dist/ [重建] +``` + +## 六、遗留阻塞(诚实记录) + +- Docker daemon 未起:`build/up` 留待 Docker 就绪的目标机;Dockerfile 多阶段构建本身未动。 +- 未上线 192.168.5.7:无服务器凭证;上线前必须设 `DJANGO_ALLOWED_HOSTS=真实域名` + (compose 现为必填,不传则 config 直接报错,不会静默带 `*` 上线)。 +- 日志轮转/备份:运维侧动作,仓库无实现(清单已如实标注)。 +- D2 Playwright 真机部分本轮未重跑(需双服务在场);脚本变更仅隔离层,10 步断言本体未动。 +- P3-1 行业专版:按规格跳过,未建模。 diff --git a/backend/apps/storefront/views.py b/backend/apps/storefront/views.py index a9bb674..ec4b4fc 100644 --- a/backend/apps/storefront/views.py +++ b/backend/apps/storefront/views.py @@ -228,18 +228,30 @@ async def my_orders(request): @api_view(["GET"]) async def admin_orders(request): - """内部:商城订单列表(默认只看待确认)。""" + """内部:商城订单列表(默认只看待确认,支持 page/page_size 分页)。""" tenant = await _tenant(request) status_filter = request.query_params.get("status", "submitted") + try: + page = max(int(request.query_params.get("page", 1)), 1) + except (TypeError, ValueError): + page = 1 + try: + page_size = int(request.query_params.get("page_size", 20)) + except (TypeError, ValueError): + page_size = 20 + page_size = min(max(page_size, 1), 200) def _list(): qs = StorefrontOrder.objects.filter(tenant=tenant).select_related("customer") if status_filter and status_filter != "all": qs = qs.filter(status=status_filter) - return list(qs.prefetch_related("lines__product").order_by("-created_at")[:100]) + total = qs.count() + rows = list(qs.prefetch_related("lines__product") + .order_by("-created_at")[(page - 1) * page_size:page * page_size]) + return total, rows - orders = await sync_to_async(_list)() - return Response({"results": [ + total, orders = await sync_to_async(_list)() + return Response({"count": total, "results": [ { "id": o.id, "order_no": o.order_no, "status": o.status, "status_name": o.get_status_display(), diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3012120..d4b5642 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -8,7 +8,8 @@ services: environment: DJANGO_SETTINGS_MODULE: config.settings.prod DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?required} - DJANGO_ALLOWED_HOSTS: "*" + # 生产禁通配符:显式域名由部署者传入,与 prod.py 的空值/通配符双门禁一致 + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:?required} DATABASE_URL: postgres://dealerhub:${POSTGRES_PASSWORD}@pg-main:5432/dealerhub REDIS_URL: redis://dsp-redis:6379/2 GRANIAN_WORKERS: "4" @@ -32,7 +33,7 @@ services: environment: DJANGO_SETTINGS_MODULE: config.settings.prod DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?required} - DJANGO_ALLOWED_HOSTS: "*" + DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:?required} DATABASE_URL: postgres://dealerhub:${POSTGRES_PASSWORD}@pg-main:5432/dealerhub REDIS_URL: redis://dsp-redis:6379/2 TASK_BROKER: redis diff --git a/backend/scripts/verify_salesman_h5.py b/backend/scripts/verify_salesman_h5.py index 56c97e2..ec93f76 100644 --- a/backend/scripts/verify_salesman_h5.py +++ b/backend/scripts/verify_salesman_h5.py @@ -13,6 +13,12 @@ import urllib.request BASE = "http://127.0.0.1:18099" PAGE = "http://127.0.0.1:15173/static/#/salesman" +# D2收尾:残留隔离 —— 每次运行用时间戳唯一收款单号 + 前后计数断言, +# 不再依赖固定 SMH50002(重复跑会撞唯一约束),且不删单(过账有副作用)。 +import time as _time +RUN_TAG = _time.strftime("%m%d%H%M%S") +RECEIPT_NO = f"SMH{ RUN_TAG }" + def api(method, path, token=None, tenant="default", body=None): req = urllib.request.Request( @@ -47,6 +53,15 @@ async def main(): s, custs = api("GET", "/api/v1/partner/customers/?page_size=200", token=token) assert s == 200 and custs["count"] >= 1, f"customers: {s}" + # 前后计数基线:验证前后差值断言(残留隔离,不删单) + s, _b0 = api("GET", "/api/v1/sales/bills/?page_size=1", token=token) + assert s == 200, f"bills baseline: {s}" + bills_before = _b0["count"] + s, _r0 = api("GET", "/api/v1/finance/receipts/?page_size=1", token=token) + assert s == 200, f"receipts baseline: {s}" + receipts_before = _r0["count"] + print(f"[pre] baseline bills={bills_before} receipts={receipts_before} " + f"run_tag={RUN_TAG} receipt_no={RECEIPT_NO}") s, whs = api("GET", "/api/v1/inventory/warehouses/?page_size=200", token=token) assert s == 200 and whs["count"] >= 1, f"warehouses: {s}" @@ -162,7 +177,7 @@ async def main(): # 关掉可能残留的下拉浮层后再填单号(Esc 关闭 popper) await page.keyboard.press("Escape") await page.wait_for_timeout(400) - bill_no = "SMH50002" + bill_no = RECEIPT_NO await page.locator("#pane-receipt input[placeholder]").nth(0).scroll_into_view_if_needed() await page.locator("#pane-receipt input[placeholder]").nth(0).fill(bill_no) amt_input = page.locator("#pane-receipt .el-input-number input") @@ -187,12 +202,21 @@ async def main(): assert latest and latest["state"] == "confirmed", \ f"expected confirmed bill, got {latest}" - s, found = api("GET", "/api/v1/finance/receipts/?page_size=50&search=SMH50002", + s, found = api("GET", f"/api/v1/finance/receipts/?page_size=50&search={RECEIPT_NO}", token=token) assert s == 200, f"receipts: {s}" - hit = [r for r in found["results"] if r["bill_no"] == "SMH50002"] - assert hit, f"receipt SMH50002 not found: {[r['bill_no'] for r in found['results'][:5]]}" - print(f"[9] receipt SMH50002 ok amount={hit[0]['amount']}") + hit = [r for r in found["results"] if r["bill_no"] == RECEIPT_NO] + assert hit, f"receipt {RECEIPT_NO} not found: {[r['bill_no'] for r in found['results'][:5]]}" + print(f"[9] receipt {RECEIPT_NO} ok amount={hit[0]['amount']}") + # 残留隔离断言:本次恰好新增 1 单 + 1 收款(可重复跑,不删单) + s, _b1 = api("GET", "/api/v1/sales/bills/?page_size=1", token=token) + assert s == 200 and _b1["count"] == bills_before + 1, \ + f"bills delta: before={bills_before} after={_b1['count']}" + s, _r1 = api("GET", "/api/v1/finance/receipts/?page_size=1", token=token) + assert s == 200 and _r1["count"] == receipts_before + 1, \ + f"receipts delta: before={receipts_before} after={_r1['count']}" + print(f"[9b] delta ok: bills {bills_before}->{_b1['count']}, " + f"receipts {receipts_before}->{_r1['count']}") s, debts = api("GET", "/api/v1/finance/receivables/?page_size=50", token=token) diff --git a/backend/tests/test_storefront.py b/backend/tests/test_storefront.py index 6dda2e0..fa26797 100644 --- a/backend/tests/test_storefront.py +++ b/backend/tests/test_storefront.py @@ -389,6 +389,37 @@ def test_admin_orders_list_default_submitted(db, auth_client, pro_plan, tenant, assert orders[0]["id"] == o1.id +def test_admin_orders_pagination(db, auth_client, pro_plan, tenant, account, + products, granted, warehouse): + """P2-1:admin_orders 支持 page/page_size 翻页,count 为过滤后总数。""" + ids = [] + for _ in range(3): + o = sf_services.submit_order( + account.customer, + lines=[{"product_id": products["P001"].id, "quantity": 1}], + ) + ids.append(o.id) + p1 = auth_client.get( + "/api/v1/storefront/admin/orders/", + {"status": "submitted", "page": 1, "page_size": 2}, + ) + assert p1.status_code == 200, p1.content + b1 = p1.json() + assert b1["count"] == 3 + assert len(b1["results"]) == 2 + p2 = auth_client.get( + "/api/v1/storefront/admin/orders/", + {"status": "submitted", "page": 2, "page_size": 2}, + ) + assert p2.status_code == 200, p2.content + b2 = p2.json() + assert b2["count"] == 3 + assert len(b2["results"]) == 1 + # 两页无重叠 + got = {r["id"] for r in b1["results"]} | {r["id"] for r in b2["results"]} + assert got == set(ids) + + def test_admin_confirm_twice_rejected(db, auth_client, pro_plan, tenant, account, products, granted, warehouse): order = sf_services.submit_order( diff --git a/docs/DEPLOY_CHECKLIST.md b/docs/DEPLOY_CHECKLIST.md index 1cb28f8..0fbbf05 100644 --- a/docs/DEPLOY_CHECKLIST.md +++ b/docs/DEPLOY_CHECKLIST.md @@ -44,11 +44,25 @@ 门禁静默通过,生产接受任意 Host。修法:显式拒绝通配符(`backend/config/settings/prod.py`), 验证见 §一#4/#5。无测试依赖 prod 通配符(全仓 grep `settings.prod` 零命中)。 +**本轮追加(2026-09-13)**:`backend/docker-compose.yml`(1Panel 上线栈)的 +`backend`/`worker` 两处 `DJANGO_ALLOWED_HOSTS: "*"` 硬编码,与 prod.py 的 +通配符门禁直接冲突 —— 该 compose 一上线就会被 prod 拒绝启动。 +修法:改为 `${DJANGO_ALLOWED_HOSTS:?required}`,与 `DJANGO_SECRET_KEY` / +`POSTGRES_PASSWORD` 同级必填,上线时必须显式传入真实域名。 +`backend/infra/docker/docker-compose.yml`(本地自带 db 栈)无此问题(未设该变量, +走 prod 门禁显式报错)。`docker compose config` 需带齐三个变量重验: +`DJANGO_SECRET_KEY=x DJANGO_ALLOWED_HOSTS=example.com POSTGRES_PASSWORD=x`。 + ## 四、诚实记录(未做,不虚报) -1. **镜像构建未跑**:需 Docker daemon 完整构建上下文(含 `npm ci` 拉包), - 本机 `docker compose config` 仅做插值校验(§一#1),`build/up` 留待目标机。 +1. **镜像构建未跑**:本机 Docker daemon 未运行(`docker compose build` 报 + `failed to connect to the docker API at npipe://...dockerDesktopLinuxEngine`), + 且首次不带变量构建即暴露插值必填(`DJANGO_SECRET_KEY/POSTGRES_PASSWORD required`)。 + `build/up` 留待 Docker 就绪的目标机。Dockerfile 多阶段构建本身未动(Node20 前端构建 + + Python3.12 后端,`frontend_dist` 由镜像内构建,不依赖宿主 `dist/`)。 2. **未上线 192.168.5.7**:无服务器凭证,`up -d` 与三接口冒烟留待部署时按 §二执行。 + 上线前必须先按 §三追加项设置 `DJANGO_ALLOWED_HOSTS=真实域名`(compose 现为必填, + 不传则 config 直接报错,不会静默带 `*` 上线)。 3. **日志轮转/备份无代码交付**:现状应用层无文件日志、无备份脚本, §二对应项为运维动作,仓库不伪造实现。 4. **P3-1 行业专版跳过**:按 NEXT_PLAN P3-1/D3 定调"按客户驱动,不提前做", diff --git a/frontend/src/pages/Salesman.vue b/frontend/src/pages/Salesman.vue index 3f520d4..22df422 100644 --- a/frontend/src/pages/Salesman.vue +++ b/frontend/src/pages/Salesman.vue @@ -230,6 +230,13 @@ import { isValidPrice as validatePrice, lineAmount, } from '../utils/transaction' +import { + linesTotal as calcLinesTotal, + taxTotal as calcTaxTotal, + roundOffAmount as calcRoundOff, + netTotal as calcNetTotal, + debtTotal as calcDebtTotal, +} from '../utils/salesman' const token = ref(localStorage.getItem('token') || '') const username = ref(localStorage.getItem('username') || '') @@ -394,21 +401,11 @@ function lineTax(p) { function rowAmount(r) { return lineAmount({ quantity: r.qty, unit_price: r.price }) } -const linesTotal = computed(() => order.value.reduce((s, r) => s + rowAmount(r), 0)) -const taxTotal = computed(() => - order.value.reduce((s, r) => { - const amt = rowAmount(r) - const rate = Number(r.taxRate || 0) - return s + (rate > 0 && amt > 0 ? amt - amt / (1 + rate) : 0) - }, 0), -) -const roundOffAmount = computed(() => { - const rt = Number(form.round_to || 0) - if (!rt) return 0 - const total = linesTotal.value - return Number((total - Math.floor(total / rt) * rt).toFixed(4)) -}) -const netTotal = computed(() => linesTotal.value - roundOffAmount.value) +// D2 收尾:合计/抹零/税额走 utils/salesman 纯函数(与 npm test 同一份实现) +const linesTotal = computed(() => calcLinesTotal(order.value)) +const taxTotal = computed(() => calcTaxTotal(order.value)) +const roundOffAmount = computed(() => calcRoundOff(linesTotal.value, form.round_to)) +const netTotal = computed(() => calcNetTotal(order.value, form.round_to)) const creditPercent = computed(() => { const limit = Number(credit.value.limit || 0) @@ -476,10 +473,8 @@ const debtsLoading = ref(false) const debtCustomer = ref(null) const debtStatusMap = { open: '未收', partial: '部分已收', paid: '已结清', cancelled: '已取消' } const debtStatusText = (s) => debtStatusMap[s] || s -const debtTotal = computed(() => - debts.value.filter((d) => d.status !== 'paid') - .reduce((s, d) => s + Number(d.balance || 0), 0), -) +// D2 收尾:欠款合计走 utils/salesman 纯函数(与 npm test 同一份实现) +const debtTotal = computed(() => calcDebtTotal(debts.value)) async function loadDebts() { debtsLoading.value = true diff --git a/frontend/src/pages/StorefrontAdmin.vue b/frontend/src/pages/StorefrontAdmin.vue index ac559ec..1de84b5 100644 --- a/frontend/src/pages/StorefrontAdmin.vue +++ b/frontend/src/pages/StorefrontAdmin.vue @@ -64,6 +64,14 @@ + @@ -149,6 +157,10 @@ const tab = ref('orders') const orders = ref([]) const ordersLoading = ref(false) const statusFilter = ref('submitted') +// P2-1:商城订单分页(后端 StandardAsyncPagination 默认 20 条,无分页会吞数据) +const ordersPage = ref(1) +const ordersPageSize = 20 +const ordersCount = ref(0) const accounts = ref([]) const accountsLoading = ref(false) @@ -171,9 +183,14 @@ async function loadOrders() { ordersLoading.value = true try { const data = await api.get('/storefront/admin/orders/', { - params: { status: statusFilter.value }, + params: { + status: statusFilter.value, + page: ordersPage.value, + page_size: ordersPageSize, + }, }) orders.value = data.results || [] + ordersCount.value = data.count ?? data.results?.length ?? 0 } finally { ordersLoading.value = false } diff --git a/frontend/src/utils/salesman.js b/frontend/src/utils/salesman.js new file mode 100644 index 0000000..a506007 --- /dev/null +++ b/frontend/src/utils/salesman.js @@ -0,0 +1,45 @@ +// D2 · 业务员 H5 纯计算(与 Salesman.vue 同口径,无框架依赖 + node --test 单测)。 +// +// 抽取原因:抹零/价内税拆分/欠款合计逻辑锁在 Salesman.vue 的 computed 里, +// 无法进 `npm test`。这里沉淀为纯函数,组件与测试共用同一份实现。 + +export function rowAmount(row) { + return Number(row?.qty || 0) * Number(row?.price || 0) +} + +export function linesTotal(rows) { + return (rows || []).reduce((s, r) => s + rowAmount(r), 0) +} + +export function lineTaxAmount(amount, rate) { + const amt = Number(amount || 0) + const r = Number(rate || 0) + if (!r || amt <= 0) return 0 + return amt - amt / (1 + r) +} + +export function taxTotal(rows) { + return (rows || []).reduce((s, r) => s + lineTaxAmount(rowAmount(r), r.taxRate), 0) +} + +export function roundOffAmount(total, roundTo) { + const rt = Number(roundTo || 0) + if (!rt) return 0 + const t = Number(total || 0) + return Number((t - Math.floor(t / rt) * rt).toFixed(4)) +} + +export function netTotal(rows, roundTo) { + const total = linesTotal(rows) + return total - roundOffAmount(total, roundTo) +} + +export function openDebts(rows) { + return (rows || []).filter((r) => r?.status === 'open' || r?.status === 'partial') +} + +export function debtTotal(rows) { + return openDebts(rows) + .filter((d) => d.status !== 'paid') + .reduce((s, d) => s + Number(d.balance || 0), 0) +} diff --git a/frontend/tests/salesman.test.mjs b/frontend/tests/salesman.test.mjs new file mode 100644 index 0000000..345e5e3 --- /dev/null +++ b/frontend/tests/salesman.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + rowAmount, + linesTotal, + lineTaxAmount, + taxTotal, + roundOffAmount, + netTotal, + openDebts, + debtTotal, +} from '../src/utils/salesman.js' + +test('rowAmount / linesTotal', () => { + assert.equal(rowAmount({ qty: 2, price: 12.5 }), 25) + assert.equal(linesTotal([{ qty: 2, price: 12.5 }, { qty: 1, price: 6 }]), 31) + assert.equal(linesTotal([]), 0) +}) + +test('lineTaxAmount uses 价内拆分 (D4 同公式)', () => { + // 113 @13% → 税 13 + assert.ok(Math.abs(lineTaxAmount(113, 0.13) - 13) < 1e-9) + assert.equal(lineTaxAmount(100, 0), 0) + assert.equal(lineTaxAmount(0, 0.13), 0) +}) + +test('taxTotal sums per-row 价内税', () => { + const rows = [ + { qty: 1, price: 113, taxRate: 0.13 }, + { qty: 1, price: 100, taxRate: 0 }, + ] + assert.ok(Math.abs(taxTotal(rows) - 13) < 1e-9) +}) + +test('roundOffAmount 抹零口径与 Salesman.vue 一致', () => { + assert.equal(roundOffAmount(31, ''), 0) + assert.equal(roundOffAmount(31, '1'), 0) + // 31.37 抹角 → 0.07 + assert.ok(Math.abs(roundOffAmount(31.37, '0.1') - 0.07) < 1e-9) + // 31.37 抹分 → 0 + assert.equal(roundOffAmount(31.37, '0.01'), 0) +}) + +test('netTotal = 行合计 - 抹零', () => { + const rows = [{ qty: 2, price: 12.5 }, { qty: 1, price: 6 }] + assert.ok(Math.abs(netTotal(rows, '0.1') - 31) < 1e-9) + assert.ok(Math.abs(netTotal([{ qty: 1, price: 31.37 }], '0.1') - 31.3) < 1e-9) +}) + +test('openDebts 只留 open/partial,debtTotal 求和 balance', () => { + const rows = [ + { id: 1, status: 'open', balance: '31.00' }, + { id: 2, status: 'partial', balance: '10.50' }, + { id: 3, status: 'paid', balance: '0' }, + ] + assert.equal(openDebts(rows).length, 2) + assert.ok(Math.abs(debtTotal(rows) - 41.5) < 1e-9) + assert.equal(debtTotal([]), 0) +})