Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2973776dc3 | ||
|
|
e0e1430e8d |
@@ -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<MMDDHHMMSS>`,
|
||||
可重复跑不撞唯一约束)+ 开单/收款前后 `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 行业专版:按规格跳过,未建模。
|
||||
@@ -0,0 +1,135 @@
|
||||
# PROGRESS_AGI_ITERATION_9 · 附录A复核补齐+D2真机重跑+P2-2本地证据
|
||||
|
||||
> 跑法口径:后端 `pytest tests/ + apps/core/tests` 全量(SQLite),前端 `npm run build` + `npm test`。
|
||||
> - 后端 SQLite:**512 passed / 4 skipped**(4 skipped 均为 PG 专用并发用例;含新增 `test_contact_crud`)
|
||||
> - 后端 PG 5433 `-m postgres`:**4 passed**(`4 passed, 498 deselected`)
|
||||
> - 前端:`npm run build` ✅ 6.17s;`npm test` ✅ 17 pass / 0 fail
|
||||
> - `manage.py check` 0 issues;`makemigrations --check` No changes
|
||||
> - D2:`verify_salesman_h5.py` 本轮真机重跑 **ALL SALESMAN-H5 CHECKS PASSED**(0 console errors)
|
||||
> - P2-2:双栈 `config` exit 0;`build/up` 因 daemon 异常未做,不虚报
|
||||
> - P3-1:按规格跳过,未建模
|
||||
|
||||
## 一、附录A复核(NEXT_PLAN §附录A 十项逐项复核,有漏就补)
|
||||
|
||||
上轮称十项全过,本轮按"页面存在 + 接真接口 + 路由 + 菜单 + 动作可用"五件套复核。
|
||||
结论:**九项全过,一项部分缺口(#10 联系人只读),本轮已补齐**。
|
||||
|
||||
| # | 后端能力 | 页面 | 接线证据 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 收款单 + 核销 | `Receivables.vue` | `GET/POST /finance/receipts/` + `POST /receipts/<id>/allocate/`(核销 dialog + 金额>0 校验 + 超额后端 400 提示) | ✅ |
|
||||
| 2 | 付款单 + 核销 | `Payables.vue` | `GET/POST /finance/payments/` + `POST /payments/<id>/allocate/`(同上) | ✅ |
|
||||
| 3 | 核销记录 | `Allocations.vue` | `GET /finance/allocations/`(应收/应付/收款/付款 ID + 金额 + 时间列) | ✅ 只读即规格(`AllocationViewSet http_method_names=["get"]`) |
|
||||
| 4 | 库存流水 | `Movements.vue` | `GET /inventory/movements/` + 分页 + count("只读,由过账自动产生"明示) | ✅ 只读即规格(ViewSet 只读) |
|
||||
| 5 | 预警规则管理 | `AlertRules.vue` | `GET/POST/PATCH/DELETE /notify/rules/` + `POST init-default`(`已初始化 N 条默认规则`)+ 分页 | ✅ |
|
||||
| 6 | 打印模板管理 | `PrintTemplates.vue` | `GET/POST/PATCH/DELETE /printing/templates/` + 新建/编辑 dialog(`formRef.validate()`) | ✅ |
|
||||
| 7 | 自定义报表 | `ReportDefinitions.vue` | `GET /report/definitions/` + `POST init-system` + `POST /<id>/run/`(结果 dialog)+ 分页 | ✅ 只读+运行即规格(无 POST/PATCH 页面动作,后端 ViewSet 允许但业务上报表由 init-system 预置) |
|
||||
| 8 | 库存状态大盘 | `Dashboard.vue` | `GET /report/dashboard/inventory-status/`(抽屉:总数量/估值/仓库数 + 各仓库明细表) | ✅ |
|
||||
| 9 | 科目管理 / 一键初始化 | `Accounts.vue` | `GET /finance/accounts/` + `POST init-chart` + `PATCH is_active` 开关 + 分页 | ✅(科目新建/编辑/删除走 init-chart 预置 + 开关管理;后端 AccountViewSet 为全 CRUD,页面未提供手工新建科目入口——与"新租户建账靠 init-chart"规格一致,不算缺口) |
|
||||
| 10 | 价格等级 / 联系人 | `PriceLevels.vue` | 等级全 CRUD ✅;联系人此前**只读**(列表+搜索+分页,无新建/编辑/删除) | ⚠️→✅ **本轮补齐**(见 §二) |
|
||||
|
||||
路由 29 条(`/login /register /storefront /salesman /storefront-admin` + 24 布局内),
|
||||
菜单 25 项(含十项全部入口),与上轮一致。
|
||||
|
||||
## 二、本轮补齐:联系人 CRUD(#10 缺口)
|
||||
|
||||
缺口:`ContactViewSet` 后端为全 CRUD,但 `ContactSerializer.Meta.fields`
|
||||
缺可写 `customer/supplier` FK(仅只读 `customer_code/supplier_code`),前端联系人
|
||||
tab 仅列表/搜索/分页。后端有接口无页面写入 → 按任务要求补齐。
|
||||
|
||||
改动:
|
||||
|
||||
```
|
||||
backend/apps/partner/serializers.py [改] ContactSerializer.fields += customer/supplier
|
||||
(Receipt pattern:FK 本体可写 + _code/_name 只读并存)
|
||||
frontend/src/pages/PriceLevels.vue [改] 联系人 tab +归属列/操作列 + 新建/编辑 dialog
|
||||
(姓名必填/邮箱格式校验 + 客户ID/供应商ID 可选挂靠,
|
||||
空值不提交)+ 新建/编辑/删除函数
|
||||
backend/tests/test_partner.py [改] +test_contact_crud(新建→编辑→列表→删除)
|
||||
frontend/dist/ [重建]
|
||||
```
|
||||
|
||||
验证:`pytest tests/test_partner.py` **8 passed**(7 原有 + 1 新增);
|
||||
全量回归 512 passed(见 §四),无回归。
|
||||
|
||||
## 三、P2-2 本地(NEXT_PLAN §P2-2 按步骤执行,不伪造上线)
|
||||
|
||||
| 步骤 | 结果 |
|
||||
|---|---|
|
||||
| `config` 插值校验(`backend/infra/docker/docker-compose.yml`,本地自带 db 栈) | ✅ exit 0(`DJANGO_SECRET_KEY=x POSTGRES_PASSWORD=x DJANGO_ALLOWED_HOSTS=example.com`) |
|
||||
| `config` 插值校验(`backend/docker-compose.yml`,1Panel 上线栈) | ✅ exit 0(同上三变量) |
|
||||
| `build backend` | ❌ 未跑:daemon 异常(`request returned 500 ... /_ping`,pipe 存在但 API 无响应;上轮为 npipe 缺失,本轮为 500,现象不同但结论同:daemon 不可用) |
|
||||
| `up -d` + 冒烟(ping/登录/开单) | ❌ 未做:daemon 不可用 + 无 192.168.5.7 服务器凭证,绝不伪造上线 |
|
||||
|
||||
与 `docs/DEPLOY_CHECKLIST.md` §四记录一致(清单称 daemon 未运行,本轮现象为
|
||||
pipe 存在但 `_ping` 500——同属"daemon 不可用",不改清单结论)。
|
||||
|
||||
## 四、D2 回归(verify 真机重跑 + 前端 salesman 回归)
|
||||
|
||||
1. **`backend/scripts/verify_salesman_h5.py` 本轮真机重跑**(dev 后端 :18099 +
|
||||
vite :15173 均 UP,Playwright Chromium,Pixel 5 移动模拟 393×851):
|
||||
|
||||
```
|
||||
[pre] baseline bills=0 receipts=0 run_tag=0914015936 receipt_no=SMH0914015936
|
||||
[pre] customer=C001 warehouse=WH01 product=B001 token=ok
|
||||
[1] login ok (mobile viewport 393x851)
|
||||
[2] customer: OK:C001 张三便利店 / warehouse: OK:WH01 主仓库
|
||||
[3] add-to-order ok (qty=2)
|
||||
[4] round_to: OK:抹角(0.1)/ submit+confirm clicked
|
||||
[5] debt tab rendered: 未结 ¥31.00(1 单)RC202609140001 … 未收
|
||||
[6] receipt customer OK / submit clicked
|
||||
[7] console errors: 0
|
||||
[8] latest bill: XS202609140001 state=confirmed total=31.0000
|
||||
[9] receipt SMH0914015936 ok amount=50.0000
|
||||
[9b] delta ok: bills 0->1, receipts 0->1
|
||||
[10] open receivables: 1, first balance=31.0000
|
||||
ALL SALESMAN-H5 CHECKS PASSED
|
||||
```
|
||||
|
||||
上轮"脚本变更仅隔离层、10 步断言本体未动故未重跑"——本轮双服务在场,已重跑,
|
||||
单列在此,不再沿用旧证据。
|
||||
|
||||
2. **前端 `npm test`(`frontend/tests/salesman.test.mjs` 6 用例在内)**:
|
||||
17 pass / 0 fail。`verify_salesman_h5.py` `py_compile` 通过。
|
||||
|
||||
## 五、最终验证数字(本轮实跑)
|
||||
|
||||
```
|
||||
backend pytest tests/ + apps/core/tests(SQLite): 512 passed / 4 skipped
|
||||
backend PG -m postgres: 4 passed (498 deselected)
|
||||
backend pytest tests/test_partner.py: 8 passed(含新增 test_contact_crud)
|
||||
frontend npm test: 17 pass / 0 fail
|
||||
frontend npm run build: ✓ 6.17s(含 PriceLevels + Salesman chunk)
|
||||
manage.py check: 0 issues;makemigrations --check: No changes
|
||||
docker compose config(双栈): exit 0 / exit 0
|
||||
build/up: daemon 不可用,未做(实报错见 §三)
|
||||
D2 verify_salesman_h5.py: ALL PASSED(0 console errors,本轮真机重跑)
|
||||
```
|
||||
|
||||
DB 异常测试口径:`tests/test_duplicate_code.py` 全文件
|
||||
`pytestmark = pytest.mark.django_db(transaction=True)`(沿用既有标记,本轮未动)。
|
||||
|
||||
## 六、变更文件清单
|
||||
|
||||
```
|
||||
backend/apps/partner/serializers.py [改] ContactSerializer +customer/supplier 可写 FK
|
||||
backend/tests/test_partner.py [改] +test_contact_crud
|
||||
frontend/src/pages/PriceLevels.vue [改] 联系人 CRUD(归属列/操作列/dialog/校验/函数)
|
||||
frontend/dist/ [重建]
|
||||
PROGRESS_AGI_ITERATION_9.md [新] 本文档
|
||||
```
|
||||
|
||||
## 七、遗留阻塞(诚实记录)
|
||||
|
||||
- Docker daemon 不可用:`build/up` 留待 daemon 就绪的目标机;Dockerfile 多阶段构建本身未动。
|
||||
- 未上线 192.168.5.7:无服务器凭证;上线前必须设 `DJANGO_ALLOWED_HOSTS=真实域名`
|
||||
(compose 现为必填,不传则 config 直接报错,不会静默带 `*` 上线)。
|
||||
- 日志轮转/备份:运维侧动作,仓库无实现(清单已如实标注)。
|
||||
- P3-1 行业专版:按规格跳过,未建模。
|
||||
- #7 自定义报表 / #9 科目管理:页面未提供手工新建入口,但与后端业务语义
|
||||
(init-system 预置 / init-chart 建账)一致,不列为缺口;若后续要手工建报表/科目,
|
||||
后端 ViewSet 已是全 CRUD,前端加 dialog 即可。
|
||||
```
|
||||
|
||||
> 附录A逐项状态:#1✅ #2✅ #3✅ #4✅ #5✅ #6✅ #7✅ #8✅ #9✅ #10✅(联系人本轮补齐)。
|
||||
> DB 异常测试用 `transaction=True`(`test_duplicate_code.py` 全文件标记,沿用)。
|
||||
> 文档只提跑通特性;数字均为本轮实跑并写明跑法。
|
||||
@@ -23,7 +23,7 @@ class ContactSerializer(AsyncModelSerializer):
|
||||
model = Contact
|
||||
fields = [
|
||||
"id", "name", "phone", "email", "title", "is_primary",
|
||||
"customer_code", "supplier_code",
|
||||
"customer", "supplier", "customer_code", "supplier_code",
|
||||
"is_deleted", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -89,3 +89,30 @@ def test_anon_cannot_access(anon, tenant):
|
||||
anon.credentials(HTTP_X_TENANT_ID=tenant.code)
|
||||
resp = anon.get("/api/v1/partner/customers/")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_contact_crud(auth_client, tenant):
|
||||
# 新建(无挂靠)
|
||||
resp = auth_client.post(
|
||||
"/api/v1/partner/contacts/",
|
||||
{"name": "张三", "phone": "13800138000", "title": "采购"},
|
||||
format="json",
|
||||
)
|
||||
assert resp.status_code == 201, resp.content
|
||||
cid = resp.json()["id"]
|
||||
# 编辑
|
||||
resp = auth_client.patch(
|
||||
f"/api/v1/partner/contacts/{cid}/",
|
||||
{"title": "采购经理"},
|
||||
format="json",
|
||||
)
|
||||
assert resp.status_code == 200, resp.content
|
||||
assert resp.json()["title"] == "采购经理"
|
||||
# 列表可见
|
||||
resp = auth_client.get("/api/v1/partner/contacts/")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["count"] == 1
|
||||
# 删除
|
||||
resp = auth_client.delete(f"/api/v1/partner/contacts/{cid}/")
|
||||
assert resp.status_code in (200, 202, 204), resp.content
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 定调"按客户驱动,不提前做",
|
||||
|
||||
@@ -22,12 +22,24 @@
|
||||
<div class="toolbar">
|
||||
<el-input v-model="contactSearch" placeholder="搜索联系人姓名" style="width: 240px" clearable @keyup.enter="loadContacts" />
|
||||
<el-button type="primary" @click="loadContacts">查询</el-button>
|
||||
<el-button type="primary" @click="openContactCreate">新建联系人</el-button>
|
||||
</div>
|
||||
<el-table :data="contacts" v-loading="contactsLoading" size="small" stripe>
|
||||
<el-table-column prop="name" label="姓名" width="140" />
|
||||
<el-table-column prop="phone" label="电话" width="150" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="160" />
|
||||
<el-table-column prop="title" label="职位" width="140" />
|
||||
<el-table-column label="归属" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.customer_code || row.supplier_code || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="removeContact(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top: 14px"
|
||||
@@ -56,6 +68,25 @@
|
||||
<el-button type="primary" :loading="saving" @click="saveLevel">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="contactDialogVisible" :title="contactEditing ? '编辑联系人' : '新建联系人'" width="480px">
|
||||
<el-form ref="contactFormRef" :model="contactForm" :rules="contactRules" label-width="100px">
|
||||
<el-form-item label="姓名" prop="name"><el-input v-model="contactForm.name" /></el-form-item>
|
||||
<el-form-item label="电话" prop="phone"><el-input v-model="contactForm.phone" /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model="contactForm.email" /></el-form-item>
|
||||
<el-form-item label="职位" prop="title"><el-input v-model="contactForm.title" /></el-form-item>
|
||||
<el-form-item label="客户ID" prop="customer">
|
||||
<el-input-number v-model="contactForm.customer" :min="0" :precision="0" style="width: 100%" placeholder="可选:挂靠客户" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商ID" prop="supplier">
|
||||
<el-input-number v-model="contactForm.supplier" :min="0" :precision="0" style="width: 100%" placeholder="可选:挂靠供应商" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="contactDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="contactSaving" @click="saveContact">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
@@ -81,6 +112,15 @@ const rules = {
|
||||
code: [{ required: true, message: '请输入等级编码', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入等级名称', trigger: 'blur' }],
|
||||
}
|
||||
const contactDialogVisible = ref(false)
|
||||
const contactEditing = ref(null)
|
||||
const contactSaving = ref(false)
|
||||
const contactFormRef = ref(null)
|
||||
const contactForm = reactive({ name: '', phone: '', email: '', title: '', customer: null, supplier: null })
|
||||
const contactRules = {
|
||||
name: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadLevels() {
|
||||
levelsLoading.value = true
|
||||
@@ -141,6 +181,48 @@ async function removeLevel(row) {
|
||||
await loadLevels()
|
||||
}
|
||||
|
||||
function openContactCreate() {
|
||||
contactEditing.value = null
|
||||
Object.assign(contactForm, { name: '', phone: '', email: '', title: '', customer: null, supplier: null })
|
||||
contactDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openContactEdit(row) {
|
||||
contactEditing.value = row
|
||||
Object.assign(contactForm, {
|
||||
name: row.name || '', phone: row.phone || '', email: row.email || '',
|
||||
title: row.title || '', customer: row.customer || null, supplier: row.supplier || null,
|
||||
})
|
||||
contactDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
const ok = await contactFormRef.value.validate().catch(() => false)
|
||||
if (!ok) return
|
||||
contactSaving.value = true
|
||||
try {
|
||||
const payload = { ...contactForm }
|
||||
if (!payload.customer) delete payload.customer
|
||||
if (!payload.supplier) delete payload.supplier
|
||||
if (contactEditing.value) await api.patch(`/partner/contacts/${contactEditing.value.id}/`, payload)
|
||||
else await api.post('/partner/contacts/', payload)
|
||||
ElMessage.success('已保存')
|
||||
contactDialogVisible.value = false
|
||||
await loadContacts()
|
||||
} finally {
|
||||
contactSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeContact(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除联系人 ${row.name}?`, '删除确认', { type: 'warning' })
|
||||
} catch (e) { return }
|
||||
await api.delete(`/partner/contacts/${row.id}/`)
|
||||
ElMessage.success('已删除')
|
||||
await loadContacts()
|
||||
}
|
||||
|
||||
onMounted(() => { loadLevels(); loadContacts() })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,14 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top: 14px"
|
||||
layout="prev, pager, next, total"
|
||||
:total="ordersCount"
|
||||
:page-size="ordersPageSize"
|
||||
v-model:current-page="ordersPage"
|
||||
@current-change="loadOrders"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 客户账号 -->
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user