D2业务员H5+移动模拟全绿 / P2-2部署清单+ALLOWED_HOSTS通配符门 / P3-1跳过

This commit is contained in:
seagull
2026-09-12 12:18:37 +08:00
parent 6c0693d160
commit eef7be7258
6 changed files with 1023 additions and 1 deletions
+116
View File
@@ -0,0 +1,116 @@
# PROGRESS_AGI_ITERATION_7 · D2 业务员 H5 + P2-2 部署清单
> 跑法口径:后端 `pytest` 全量(`tests/` + `apps/core/tests`),前端 `npm run build` + `npm test`。
> - 后端 SQLite:**510 passed / 4 skipped**(4 skipped 均为 PG 专用并发用例)
> - 后端 PG 5433 `-m postgres`:**4 passed**
> - 前端:`npm run build` ✅ 5.64s;`npm test` ✅ 11 pass / 0 fail
> - `manage.py check` 0 issues;`makemigrations --check` No changes
> - D2 移动模拟:`backend/scripts/verify_salesman_h5.py` 全 10 步通过,0 console errors
> - P2-2:`docker compose config` exit 0 + `docs/DEPLOY_CHECKLIST.md` 落地;镜像构建/上线因无凭证未做,不虚报
> - P3-1:按规格跳过,未建模
## 一、P2-3 D2 业务员移动开单 H5(`PROGRESS_BATCH_D.md:236` + `NEXT_PLAN.md` §P2-3)
规格原文:复用 `Storefront.vue` 外壳 + `SalesBills` 取价/单位/抹零组件,
换业务员角色(外勤开单/查欠款/收款登记),Chrome 移动模拟全流程走通。
### 交付
- 新建 `frontend/src/pages/Salesman.vue`(约 550 行,`dist/assets/Salesman-*.js` 16.26 kB):
- 外壳:复用 Storefront 的 `sf-wrap` 移动布局(max-width 560px + 480px 断点纵排),
登录用后台用户 JWT(`POST /auth/token/`,与 PC 同一套账号),公开路由 `/#/salesman`。
- 外勤开单:客户/仓库下拉 + 商品搜索 + 单位下拉 + 数量;
取价走 `GET /partner/price-quote/`(客户专属价→等级价→上次成交价→默认价,
与 SalesBills 同源,source 标签同文案);单位换算走
`GET /catalog/products/<id>/units/` + `utils/transaction.js` 的 `unitRate/lineAmount`
(与 SalesBills 同口径);税率/税额行内显示(价内拆分,与 D4 同公式);
抹零(不抹/抹分/抹角/抹元)+ 应收合计;保存走 `POST /sales/bills/create-bill/`
后自动 `POST /sales/bills/<id>/confirm/`(402 超限弹强制过账确认,与 SalesBills 同流程)。
- 查欠款:`GET /finance/receivables/` 只留 open/partial(前端过滤,BaseTenantViewSet
无精确 customer 过滤器,不伪造后端参数),显示单号/客户/日期/共欠/已收/未结余额 + 状态标签 + 未结合计。
- 收款登记:`POST /finance/receipts/`(客户/单号必填/日期/金额>0/方式/备注),
单号必填是实测结论(Receipt.bill_no 无自生成,漏填 400),表单 rules 与
`Receivables.vue` 登记 dialog 同约束。
- 路由:`frontend/src/router/index.js` 加 `/salesman` + `PUBLIC_ROUTES` 加 `salesman`
(P0-3 同类坑:不加则匿名被踢回 login)。
### 验证(干净库实跑,非残留数据)
`backend/scripts/verify_salesman_h5.py`(Playwright Chromium,Pixel 5 移动模拟
393×851 + touch + 移动 UA),dev 后端 :18099 + vite :15173(临时 verify 配置代理 /api):
```
[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: 未结 ¥31.00(1 单)RC202609120001 … 未收
[6] receipt customer OK / submit clicked
[7] console errors: 0
[8] latest bill: XS202609120001 state=confirmed total=31.0000
[9] receipt SMH50002 ok amount=50.0000
[10] open receivables: 1, first balance=31.0000
ALL SALESMAN-H5 CHECKS PASSED
```
验证后残留(XS202609120001/SMH50002/RC202609120001)已删,`default` 租户恢复 0 单据。
为验证造的最小数据保留(alice/alice12345 + B001/C001/WH01/1000 库存),属可用 fixture。
### 踩坑
1. 密码框无 placeholder(只有前两个 input 有),用 `.sm-login input` 序号定位。
2. 移动视口下 `li[role=option]`(非 `.el-select-dropdown .el-option`)且常判不可见,
Playwright click(连 force)会被 visible 挡 → 一律用 JS `offsetParent` 过滤可见项后 `.click()`。
3. 收款 tab 切换用 `#tab-receipt/#pane-receipt` 稳定选择器,不用 role 或 is-active。
4. 填单号前先 `Escape` 关残留 popper,否则输入框被浮层挡住无法 fill。
## 二、P2-2 部署 readiness(`NEXT_PLAN.md` §P2-2)
- `docs/DEPLOY_CHECKLIST.md`(新建):本地校验表(7 项全 ✅ exit 0/实测值)+
上线 gate(环境变量/构建/迁移/collectstatic/DEBUG/PG/HTTPS/日志轮转/备份/冒烟)+
诚实记录(未构建镜像、未上线、无备份脚本,均不虚报)。
- 附带真缺口修复:`backend/config/settings/prod.py` 拒绝 `DJANGO_ALLOWED_HOSTS` 含 `*`。
根因:`base.py:read_env` 把 dev `.env`(`DJANGO_ALLOWED_HOSTS=*`)写进 `os.environ`,
prod 空值门读到 `*` 静默通过。验证:不传变量 → `ImproperlyConfigured(通配符)`;
`=example.com` → check 0 issues。全仓 grep `settings.prod` 零命中,无测试依赖。
- 本地校验实测:`docker compose config` exit 0(插值全展开);
prod `check` 0 issues;`collectstatic` 216 files;prod 基线
(DEBUG=False / dealerhub INFO / PG pool 2/10/10 / HSTS 30 天+include+preload /
Secure Cookie / proxy https / DENY)逐项打印确认。
## 三、P3-1 跳过声明
按 `NEXT_PLAN.md` §P3-1 与 `PROGRESS_BATCH_D.md:238` 定调"按客户驱动,不提前做":
本轮未建 `Serial`/辅助属性/套装拆件,未动 inventory/catalog 模型,
`makemigrations --check` No changes 即为预期状态。
## 四、最终验证数字(本轮实跑)
```
backend pytest tests/(SQLite): 496 passed / 4 skipped
backend pytest 全量(含 core/tests): 510 passed / 4 skipped
backend PG -m postgres: 4 passed
frontend npm run build: ✓ 5.64s(含 Salesman-*.js 16.26 kB)
frontend npm test: 11 pass / 0 fail
manage.py check: 0 issues;makemigrations --check: No changes
D2 verify_salesman_h5.py: ALL PASSED(0 console errors)
```
## 五、变更文件清单
```
frontend/src/pages/Salesman.vue [新] D2 业务员 H5 三职一页
frontend/src/router/index.js [改] +/salesman 路由 + PUBLIC_ROUTES
backend/scripts/verify_salesman_h5.py [新] D2 移动模拟全流程脚本(10 步断言)
backend/config/settings/prod.py [改] ALLOWED_HOSTS 拒绝通配符 *
docs/DEPLOY_CHECKLIST.md [新] P2-2 readiness gate 清单
frontend/dist/ [重建]
```
## 六、遗留
- 镜像构建 + 上线 192.168.5.7:无 Docker 目标机/服务器凭证,停在清单 + 本地校验。
- 日志轮转/备份:运维侧动作,仓库无实现(清单已如实标注)。
- D2 欠款页用前端过滤(open/partial + 按客户),数据量大时应补后端 `customer/status`
精确过滤;当前为 H5 外勤场景可接受范围。
+8
View File
@@ -16,6 +16,14 @@ if not _configured_hosts:
ALLOWED_HOSTS = [h.strip() for h in _configured_hosts.split(",") if h.strip()]
if not ALLOWED_HOSTS:
raise ImproperlyConfigured("DJANGO_ALLOWED_HOSTS 解析为空,请填入实际域名")
if "*" in ALLOWED_HOSTS:
# base 的 read_env 会把 backend/.env(dev 用,含 DJANGO_ALLOWED_HOSTS=*)
# 写进 os.environ;生产若不显式覆盖就会静默接受任意 Host。
# 通配符在生产无意义,直接拒绝,强制部署者显式声明域名。
raise ImproperlyConfigured(
"生产环境不允许 DJANGO_ALLOWED_HOSTS 含通配符 '*'(可能继承了 dev 的 .env),"
"请显式设置真实域名,如 DJANGO_ALLOWED_HOSTS=erp.example.com"
)
# PostgreSQL(用 DATABASE_URL 环境变量)
DATABASES = {
+212
View File
@@ -0,0 +1,212 @@
"""D2 业务员移动开单 H5 全流程验证(Chrome 移动模拟)。
跑法: python scripts/verify_salesman_h5.py
前置: 后端 dev 在 127.0.0.1:18099, vite(verify配置) 在 127.0.0.1:15173
流程: 登录(alice/default) → 开单tab(客户/仓库/商品/单位/取价/数量/抹零/保存过账)
→ 欠款tab(未结单+余额) → 收款tab(登记收款单) → 断言后端落库
"""
import asyncio
import json
import sys
import urllib.request
BASE = "http://127.0.0.1:18099"
PAGE = "http://127.0.0.1:15173/static/#/salesman"
def api(method, path, token=None, tenant="default", body=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode() if body is not None else None,
method=method,
headers={"Content-Type": "application/json"},
)
if token:
req.add_header("Authorization", f"Bearer {token}")
req.add_header("X-Tenant-Id", tenant)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read() or b"{}")
except Exception:
return e.code, {}
async def main():
from playwright.async_api import async_playwright
errors = []
# --- 后端预检: 登录拿 token ---
import urllib.error # noqa
s, login = api("POST", "/api/v1/auth/token/",
body={"username": "alice", "password": "alice12345"})
assert s == 200, f"login failed: {s} {login}"
token = login["access"]
s, custs = api("GET", "/api/v1/partner/customers/?page_size=200",
token=token)
assert s == 200 and custs["count"] >= 1, f"customers: {s}"
s, whs = api("GET", "/api/v1/inventory/warehouses/?page_size=200",
token=token)
assert s == 200 and whs["count"] >= 1, f"warehouses: {s}"
s, prods = api("GET", "/api/v1/catalog/products/?page_size=200",
token=token)
assert s == 200 and prods["count"] >= 1, f"products: {s}"
customer = custs["results"][0]
warehouse = whs["results"][0]
product = prods["results"][0]
print(f"[pre] customer={customer['code']} warehouse={warehouse['code']} "
f"product={product['code']} token=ok")
async with async_playwright() as pw:
browser = await pw.chromium.launch()
# Chrome 移动模拟: Pixel 5 视口 + 触摸 + 移动 UA
ctx = await browser.new_context(
viewport={"width": 393, "height": 851},
has_touch=True,
is_mobile=True,
user_agent=("Mozilla/5.0 (Linux; Android 13; Pixel 5) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0 Mobile Safari/537.36"),
)
page = await ctx.new_page()
page.on("console", lambda m: errors.append(m.text)
if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
await page.goto(PAGE, wait_until="networkidle")
# --- 登录 ---(密码框无 placeholder,用 input 序号定位)
inputs = page.locator(".sm-login input")
await inputs.nth(0).fill("alice")
await inputs.nth(1).fill("alice12345")
await page.get_by_role("button", name="登 录").click()
await page.wait_for_timeout(3000)
body = await page.content()
assert "外勤开单" in body or "开单" in body, "login did not land"
print("[1] login ok (mobile viewport 393x851)")
# --- 开单: 选客户/仓库 ---
# 移动视口下 el-option 常判定不可见,用 JS 直点可见 option
async def pick(select_idx, opt_idx=0):
sels = page.locator(".el-select")
await sels.nth(select_idx).click()
await page.wait_for_timeout(900)
txt = await page.evaluate("""(idx) => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (!vis.length || idx >= vis.length) return 'NONE total='+opts.length;
vis[idx].click();
return 'OK:' + vis[idx].textContent.trim().slice(0, 30);
}""", opt_idx)
await page.wait_for_timeout(1200)
return txt
print("[2] customer:", await pick(0, 0))
print("[2] warehouse:", await pick(1, 0))
# 搜商品并加一行: 取第一行的数量框填 2, 点"加"
await page.wait_for_timeout(2000)
add_btns = page.get_by_role("button", name="加", exact=True)
n_add = await add_btns.count()
assert n_add >= 1, "no product rows rendered"
qty_boxes = page.locator(".sm-item .el-input-number input")
await qty_boxes.first.fill("2")
await add_btns.first.click()
await page.wait_for_timeout(800)
order_title = await page.locator(".sm-order-title").count()
assert order_title == 1, "order section not shown after add"
print("[3] add-to-order ok (qty=2)")
# 抹零选抹角 + 保存并过账
await page.locator(".sm-totals .el-select").click()
await page.wait_for_timeout(700)
print("[4] round_to:",
await page.evaluate("""() => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (vis.length < 3) return 'NONE total='+opts.length;
vis[2].click();
return 'OK:' + vis[2].textContent.trim().slice(0, 20);
}"""))
await page.wait_for_timeout(500)
await page.get_by_role("button", name="保存并过账").click()
await page.wait_for_timeout(4000)
print("[4] submit+confirm clicked")
# --- 欠款 tab ---(tab 项是 div#tab-xxx,直接点)
await page.locator("#tab-debt").click()
await page.wait_for_timeout(2500)
debt_body = await page.locator("#pane-debt").text_content()
print(f"[5] debt tab rendered: {(debt_body or '').strip()[:100]}")
assert "未结" in (debt_body or "") or "暂无欠款" in (debt_body or ""), \
f"debt pane unexpected: {(debt_body or '')[:100]}"
# --- 收款 tab: 填单登记 ---
await page.locator("#tab-receipt").click()
await page.wait_for_timeout(1000)
# 收款面板内第一个 select = 客户
cust_sel = page.locator("#pane-receipt .el-select").first
await cust_sel.scroll_into_view_if_needed()
await cust_sel.click()
await page.wait_for_timeout(900)
print("[6] receipt customer:",
await page.evaluate("""() => {
const opts = document.querySelectorAll('li[role=option]');
const vis = [...opts].filter(o => o.offsetParent !== null);
if (!vis.length) return 'NONE';
vis[0].click();
return 'OK:' + vis[0].textContent.trim().slice(0, 30);
}"""))
await page.wait_for_timeout(500)
# 关掉可能残留的下拉浮层后再填单号(Esc 关闭 popper)
await page.keyboard.press("Escape")
await page.wait_for_timeout(400)
bill_no = "SMH50002"
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")
await amt_input.fill("50")
await page.get_by_role("button", name="登记收款").click()
await page.wait_for_timeout(3000)
print("[6] receipt submit clicked")
errs = [e for e in errors if "favicon" not in e.lower()]
print(f"[7] console errors: {len(errs)}")
for e in errs[:10]:
print(" ERR:", e[:200])
await browser.close()
# --- 后端断言落库 ---
s, bills = api("GET", "/api/v1/sales/bills/?page_size=5", token=token)
assert s == 200, f"bills: {s}"
latest = bills["results"][0] if bills["results"] else None
print(f"[8] latest bill: {latest['bill_no'] if latest else None} "
f"state={latest['state'] if latest else None} "
f"total={latest['total_amount'] if latest else None}")
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",
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']}")
s, debts = api("GET", "/api/v1/finance/receivables/?page_size=50",
token=token)
assert s == 200
open_debts = [d for d in debts["results"]
if d["status"] in ("open", "partial")]
assert open_debts, "expected open receivables"
print(f"[10] open receivables: {len(open_debts)}, "
f"first balance={open_debts[0]['balance']}")
real_errors = [e for e in errors if "favicon" not in e.lower()]
assert not real_errors, f"console errors: {real_errors[:5]}"
print("ALL SALESMAN-H5 CHECKS PASSED")
if __name__ == "__main__":
sys.exit(asyncio.run(main()) or 0)
+55
View File
@@ -0,0 +1,55 @@
# dealerhub · 生产部署 Readiness Gate 清单(P2-2)
> 状态:**本地校验通过,未实际部署**。唯一会碰生产动作的项(镜像构建/上线
> 192.168.5.7)因无服务器凭证,停在"清单 + 本地校验",不伪造部署成功。
> 验证环境:Windows + Docker 29.7.2 + Python 3.12.10,2026-09-12 实跑。
## 一、本地校验(已跑通,exit 0)
| # | 项 | 命令 | 结果 |
|---|---|---|---|
| 1 | compose 插值 | `DJANGO_SECRET_KEY='x' POSTGRES_PASSWORD='x' docker compose -f backend/infra/docker/docker-compose.yml config` | ✅ exit 0,`DATABASE_URL`/`SECRET_KEY`/`POSTGRES_PASSWORD` 均已插值,无 `required` 残留告警 |
| 2 | prod check | `DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=dummy DJANGO_ALLOWED_HOSTS=example.com DATABASE_URL=postgres://u:p@localhost:5432/db python manage.py check` | ✅ `System check identified no issues (0 silenced)` |
| 3 | collectstatic | 同上环境 `python manage.py collectstatic --noinput`(实跑非 dry-run,写 `backend/staticfiles/`) | ✅ `216 static files copied` |
| 4 | ALLOWED_HOSTS 空值门 | 不传 `DJANGO_ALLOWED_HOSTS`(仅继承 dev `.env` 的 `*`)→ `manage.py check` | ✅ 被拒:`生产环境不允许 DJANGO_ALLOWED_HOSTS 含通配符 '*'`(本次新增,见 §三) |
| 5 | ALLOWED_HOSTS 显式域名 | `DJANGO_ALLOWED_HOSTS=example.com` → `manage.py check` | ✅ 0 issues |
| 6 | prod 安全基线 | 同环境打印 settings | ✅ `DEBUG=False`;`dealerhub` logger `INFO`;PG `pool min2/max10/timeout10`;HSTS `2592000+include+preload`;`SESSION/CSRF_COOKIE_SECURE=True`;`SECURE_PROXY_SSL_HEADER=https`;`X_FRAME_OPTIONS=DENY` |
| 7 | DB 方言 | prod `ENGINE=django.db.backends.postgresql` | ✅ 非 SQLite(SQLite 不适合生产并发,见 NEXT_PLAN P1-1) |
## 二、上线 192.168.5.7 前的 Gate(逐项打勾才能上)
- [ ] **环境变量**:目标机上设置 `DJANGO_SECRET_KEY`(≥32 随机)、`POSTGRES_PASSWORD`、
`DJANGO_ALLOWED_HOSTS=实际域名`(逗号分隔,禁 `*`)、`DATABASE_URL`(PG)。
参考:` §一#1` 的插值校验。
- [ ] **镜像构建**:`docker compose -f backend/infra/docker/docker-compose.yml build backend`
(Node+Python 多阶段,镜像内 `npm ci && npm run build`,不依赖宿主 `dist/`)。
- [ ] **迁移**:容器启动脚本 `infra/scripts/run_prod_granian.sh` 自动
`python manage.py migrate --noinput`;上线后用 `manage.py showmigrations` 抽查无未应用迁移。
- [ ] **collectstatic**:同脚本自动 `collectstatic --noinput`;用 `curl /static/` 抽查 200。
- [ ] **`DJANGO_DEBUG=False`**:prod 已硬编码 `DEBUG=False`(`config/settings/prod.py`),
用 §一#6 命令复验。
- [ ] **PG 已切**:`DATABASE_URL` 指向生产 PG(本机 PG 5433 仅用于并发测试,不可当生产库)。
- [ ] **HTTPS**:`SECURE_PROXY_SSL_HEADER + HSTS + Secure Cookie` 已配(§一#6),
前置反代(1Panel/nginx)必须透传 `X-Forwarded-Proto: https` 并配证书;`curl -I https://域名` 验 HSTS 头。
- [ ] **日志轮转**:应用层只打 console(`StreamHandler`,无文件 handler,见 §四);
靠宿主 `docker logs --log-opt max-size/max-file` 或 journald 做轮转,**不要**在容器内写日志文件。
- [ ] **备份**:PG 每日 `pg_dump`(或卷快照)+ 留存 ≥7 天,恢复演练每季度一次。
现状:仓库内无备份脚本(§四),上线前由运维侧补齐,本清单不虚构。
- [ ] **冒烟**:`GET /api/v1/ping/` → 登录 → 开单,三接口 200(D2 移动端同链路已在本地验证,见 `backend/scripts/verify_salesman_h5.py`)。
## 三、本轮修补(P2-2 附带真缺口)
`prod.py` 的 ALLOWED_HOSTS 空值门可被 dev `.env`(`DJANGO_ALLOWED_HOSTS=*`,
经 `base.py:read_env` 写进 `os.environ`)绕过:不传变量时读到 `*` 而非空,
门禁静默通过,生产接受任意 Host。修法:显式拒绝通配符(`backend/config/settings/prod.py`),
验证见 §一#4/#5。无测试依赖 prod 通配符(全仓 grep `settings.prod` 零命中)。
## 四、诚实记录(未做,不虚报)
1. **镜像构建未跑**:需 Docker daemon 完整构建上下文(含 `npm ci` 拉包),
本机 `docker compose config` 仅做插值校验(§一#1),`build/up` 留待目标机。
2. **未上线 192.168.5.7**:无服务器凭证,`up -d` 与三接口冒烟留待部署时按 §二执行。
3. **日志轮转/备份无代码交付**:现状应用层无文件日志、无备份脚本,
§二对应项为运维动作,仓库不伪造实现。
4. **P3-1 行业专版跳过**:按 NEXT_PLAN P3-1/D3 定调"按客户驱动,不提前做",
本轮未建 `Serial`/辅助属性/套装拆件,grep 仍零命中即为预期状态。
+630
View File
@@ -0,0 +1,630 @@
<template>
<div class="sm-wrap">
<!-- 登录(业务员 = 后台用户 JWT,与 PC 端同一套账号) -->
<div v-if="!token" class="sm-login">
<h2>业务员开单</h2>
<p class="sm-sub">外勤开单 / 查欠款 / 收款登记,用公司账号登录</p>
<el-form :model="login" label-position="top" @keyup.enter="doLogin">
<el-form-item label="用户名">
<el-input v-model="login.username" placeholder="如 alice" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="login.password" type="password" show-password />
</el-form-item>
<el-form-item label="租户编码">
<el-input v-model="login.tenant" placeholder="如 default" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="loading" @click="doLogin">
登 录
</el-button>
</el-form>
</div>
<!-- 主界面 -->
<template v-else>
<div class="sm-head">
<div>
<div class="sm-shop">业务员开单</div>
<div class="sm-sub">已登录 {{ username }} · {{ tenant }}</div>
</div>
<el-button text size="small" @click="logout">退出</el-button>
</div>
<el-tabs v-model="tab">
<!-- 外勤开单 -->
<el-tab-pane label="开单" name="bill">
<el-form label-position="top" size="small">
<el-form-item label="客户" required>
<el-select
v-model="form.customer" filterable placeholder="选择客户"
style="width: 100%" @change="onCustomerChange"
>
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="仓库" required>
<el-select v-model="form.warehouse" placeholder="选择仓库" style="width: 100%">
<el-option
v-for="w in warehouses" :key="w.id"
:label="`${w.code} ${w.name}`" :value="w.id"
/>
</el-select>
</el-form-item>
</el-form>
<!-- 信用额度条(复用 SalesBills 取价页同口径) -->
<div v-if="Number(credit.limit) > 0" class="credit-bar">
<span class="credit-label">
信用:已用 ¥{{ credit.outstanding }} / 共 ¥{{ credit.limit }}
</span>
<el-progress
:percentage="creditPercent" :status="creditStatus" :stroke-width="10"
style="flex: 1; min-width: 120px"
/>
</div>
<div class="sm-search">
<el-input
v-model="search" placeholder="搜索商品名/编码" clearable
@keyup.enter="filterProducts" @clear="filterProducts"
>
<template #append><el-button @click="filterProducts">搜</el-button></template>
</el-input>
</div>
<div class="sm-list" v-loading="catalogLoading">
<div v-for="p in visibleProducts" :key="p.id" class="sm-item">
<div class="sm-item-main">
<div class="sm-name">{{ p.name }}</div>
<div class="sm-meta">{{ p.code }} | {{ p.spec || '—' }}</div>
<div class="sm-price">
¥ {{ lineOf(p).price.toFixed(2) }}
<span class="sm-price-unit">/ {{ lineOf(p).unitName }}</span>
<el-tag v-if="lineOf(p).source" size="small" type="info" effect="plain">
{{ sourceText(lineOf(p).source) }}
</el-tag>
</div>
<div v-if="lineOf(p).taxRate > 0" class="sm-tax">
税率 {{ (lineOf(p).taxRate * 100).toFixed(0) }}% · 税 ¥{{ lineTax(p) }}
</div>
</div>
<div class="sm-item-ops">
<el-input-number
v-model="lineOf(p).qty" :min="0" size="small"
:controls="false" style="width: 64px"
/>
<el-select
v-model="lineOf(p).unitId" size="small" style="width: 84px"
@change="() => onUnitChange(p)"
>
<el-option
v-for="u in (lineOf(p).units || [])" :key="u.unit_id"
:label="u.unit_name" :value="u.unit_id"
/>
</el-select>
<el-button size="small" type="primary" @click="addLine(p)">加</el-button>
</div>
</div>
<el-empty v-if="!catalogLoading && !visibleProducts.length" description="暂无商品" />
</div>
<!-- 当前单据 -->
<div v-if="order.length" class="sm-order">
<div class="sm-order-title">当前单据({{ order.length }} 行)</div>
<div v-for="(r, i) in order" :key="i" class="sm-order-row">
<span class="sm-order-name">{{ r.name }} × {{ r.qty }}{{ r.unitName }}</span>
<span>¥ {{ (r.qty * r.price).toFixed(2) }}</span>
<el-button link type="danger" size="small" @click="order.splice(i, 1)">删</el-button>
</div>
<div class="sm-totals">
<span>行合计 ¥ {{ linesTotal.toFixed(2) }}</span>
<span v-if="taxTotal > 0" class="sm-tax-total">其中税 ¥{{ taxTotal.toFixed(2) }}</span>
</div>
<div class="sm-totals">
<span>抹零</span>
<el-select v-model="form.round_to" size="small" style="width: 120px">
<el-option label="不抹零" value="" />
<el-option label="抹分(0.01)" value="0.01" />
<el-option label="抹角(0.1)" value="0.1" />
<el-option label="抹元(1)" value="1" />
</el-select>
<span v-if="roundOffAmount > 0" class="sm-round">- ¥{{ roundOffAmount.toFixed(2) }}</span>
<b class="sm-net">应收 ¥{{ netTotal.toFixed(2) }}</b>
</div>
<el-button
type="primary" style="width: 100%; margin-top: 8px"
:loading="submitting" @click="submitBill"
>
保存并过账
</el-button>
</div>
</el-tab-pane>
<!-- 查欠款 -->
<el-tab-pane label="欠款" name="debt">
<el-form label-position="top" size="small">
<el-form-item label="客户">
<el-select
v-model="debtCustomer" filterable clearable placeholder="全部客户"
style="width: 100%" @change="loadDebts"
>
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="sm-debt-sum" v-if="debts.length">
未结 <b>¥ {{ debtTotal.toFixed(2) }}</b>({{ debts.length }} 单)
</div>
<div class="sm-list" v-loading="debtsLoading">
<div v-for="d in debts" :key="d.id" class="sm-debt">
<div class="sm-item-main">
<div class="sm-name">{{ d.bill_no }}</div>
<div class="sm-meta">{{ d.customer_name }} | {{ d.bill_date }}</div>
<div class="sm-price">
未结 ¥ {{ Number(d.balance).toFixed(2) }}
<span class="sm-price-unit">(共 ¥{{ Number(d.total_amount).toFixed(2) }},已收 ¥{{ Number(d.paid_amount).toFixed(2) }})</span>
</div>
</div>
<el-tag :type="d.status === 'paid' ? 'success' : d.status === 'partial' ? 'warning' : 'danger'" effect="plain" size="small">
{{ debtStatusText(d.status) }}
</el-tag>
</div>
<el-empty v-if="!debtsLoading && !debts.length" description="该客户暂无欠款" />
</div>
</el-tab-pane>
<!-- 收款登记 -->
<el-tab-pane label="收款" name="receipt">
<el-form :model="receipt" label-position="top" size="small" :rules="receiptRules" ref="receiptRef">
<el-form-item label="客户" prop="customer" required>
<el-select v-model="receipt.customer" filterable placeholder="选择客户" style="width: 100%">
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="收款单号" prop="bill_no" required>
<el-input v-model="receipt.bill_no" placeholder="如 SK202609120001(租户内唯一)" maxlength="64" />
</el-form-item>
<el-form-item label="收款日期" prop="bill_date" required>
<el-date-picker v-model="receipt.bill_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="收款金额" prop="amount" required>
<el-input-number v-model="receipt.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="收款方式">
<el-select v-model="receipt.method" placeholder="现金/银行/微信/支付宝" clearable style="width: 100%">
<el-option label="现金" value="现金" />
<el-option label="银行" value="银行" />
<el-option label="微信" value="微信" />
<el-option label="支付宝" value="支付宝" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="receipt.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="receiptSaving" @click="saveReceipt">
登记收款
</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api/client'
import {
unitRate,
isValidQuantity as validateQuantity,
isValidPrice as validatePrice,
lineAmount,
} from '../utils/transaction'
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const tenant = ref(localStorage.getItem('tenant') || 'default')
const loading = ref(false)
const tab = ref('bill')
const login = reactive({
username: localStorage.getItem('username') || '',
password: '',
tenant: localStorage.getItem('tenant') || 'default',
})
const customers = ref([])
const warehouses = ref([])
const products = ref([])
const visibleProducts = ref([])
const search = ref('')
const catalogLoading = ref(false)
const submitting = ref(false)
const credit = ref({})
const form = reactive({
customer: null, warehouse: null, round_to: '',
})
/** productId → {qty, unitId, unitName, units, price, source, taxRate}(模板纯读,无副作用) */
const lineState = reactive({})
const order = ref([])
const sourceMap = {
customer: '客户专属价', level: '等级价', last: '上次成交价', default: '默认售价',
}
const sourceText = (s) => sourceMap[s] || s
async function doLogin() {
if (!login.username || !login.password) { ElMessage.warning('请输入用户名与密码'); return }
if (!login.tenant?.trim()) { ElMessage.warning('请输入租户编码'); return }
loading.value = true
try {
// api client 的 baseURL 已是 /api/v1,此处用相对路径(与 Login.vue 的全路径等价)
const data = await api.post('/auth/token/', {
username: login.username, password: login.password,
})
localStorage.setItem('token', data.access)
localStorage.setItem('username', login.username)
localStorage.setItem('tenant', login.tenant.trim() || 'default')
localStorage.removeItem('readonly')
token.value = data.access
username.value = login.username
tenant.value = login.tenant.trim() || 'default'
await loadRefs()
await loadDebts()
} catch (e) {
ElMessage.error('登录失败:用户名/密码/租户不正确')
} finally {
loading.value = false
}
}
function logout() {
localStorage.removeItem('token')
token.value = ''
order.value = []
}
function lineOf(p) {
if (!lineState[p.id]) {
lineState[p.id] = reactive({
qty: 0, unitId: null, unitName: '', units: [], price: 0, source: '', taxRate: 0,
})
void initLine(p)
}
return lineState[p.id]
}
async function initLine(p) {
const row = lineState[p.id]
try {
const info = await api.get(`/catalog/products/${p.id}/units/`)
row.units = info.units || []
const base = row.units.find((u) => u.is_base) || row.units[0]
row.unitId = base?.unit_id ?? null
row.unitName = base?.unit_name || ''
row.price = Number(base?.price ?? info.sale_price ?? 0)
row.taxRate = Number(info.tax_rate || 0)
} catch (e) {
row.price = Number(p.sale_price || 0)
}
}
function onUnitChange(p) {
const row = lineOf(p)
const u = (row.units || []).find((x) => x.unit_id === row.unitId) || row.units[0]
row.unitName = u?.unit_name || ''
// 切单位后按同口径重算单价:已有取价用取价换算,否则用基本售价换算
if (row.baseQuotePrice != null) {
row.price = Number(row.baseQuotePrice) * unitRate({ source_unit: row.unitId, units: row.units })
} else {
row.price = Number(u?.price ?? row.price ?? 0)
}
if (form.customer) void fetchQuote(p)
}
async function fetchQuote(p) {
if (!form.customer) return
const row = lineOf(p)
try {
const data = await api.get('/partner/price-quote/', {
params: { customer_id: form.customer, product_ids: p.id },
})
const q = (data.results || [])[0]
if (!q) return
row.source = q.source || ''
row.baseQuotePrice = Number(q.price)
row.price = Number(q.price) * unitRate({ source_unit: row.unitId, units: row.units })
} catch (e) {
/* 取价失败不阻断开单 */
}
}
async function onCustomerChange() {
credit.value = {}
if (!form.customer) return
try {
credit.value = await api.get(`/partner/credit-usage/${form.customer}/`)
} catch (e) {
/* 无额度信息不阻断 */
}
for (const p of visibleProducts.value) {
if (lineState[p.id]) await fetchQuote(p)
}
}
function filterProducts() {
const kw = search.value.trim().toLowerCase()
if (!kw) { visibleProducts.value = products.value; return }
visibleProducts.value = products.value.filter(
(p) => (p.name || '').toLowerCase().includes(kw) || (p.code || '').toLowerCase().includes(kw),
)
}
function addLine(p) {
const row = lineOf(p)
if (!row.qty || row.qty <= 0) { ElMessage.warning('请填写数量'); return }
order.value.push({
product: p.id, name: p.name, qty: row.qty,
unitId: row.unitId, unitName: row.unitName, units: row.units,
price: row.price, taxRate: row.taxRate,
})
row.qty = 0
ElMessage.success(`已加入:${p.name}`)
}
function lineTax(p) {
const row = lineOf(p)
const amt = Number(row.qty || 0) * Number(row.price || 0)
const rate = Number(row.taxRate || 0)
if (!rate || amt <= 0) return '0.00'
return (amt - amt / (1 + rate)).toFixed(2)
}
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)
const creditPercent = computed(() => {
const limit = Number(credit.value.limit || 0)
if (!limit) return 0
return Math.min(100, Math.round(Number(credit.value.outstanding || 0) / limit * 100))
})
const creditStatus = computed(() => {
if (creditPercent.value >= 100) return 'exception'
if (creditPercent.value >= 80) return 'warning'
return 'success'
})
async function submitBill() {
if (!form.customer) { ElMessage.warning('请选择客户'); return }
if (!form.warehouse) { ElMessage.warning('请选择仓库'); return }
const badQty = order.value.filter((r) => !validateQuantity({ quantity: r.qty }))
if (badQty.length) { ElMessage.error('有明细数量不合法(需 ≥0.001 且 ≤10 亿)'); return }
const badPrice = order.value.filter((r) => !validatePrice({ unit_price: r.price }))
if (badPrice.length) { ElMessage.error('有明细单价不合法(必须大于 0 且不超过 10 亿)'); return }
const payload = {
customer: form.customer,
warehouse: form.warehouse,
round_to: form.round_to || undefined,
lines: order.value.map((r) => ({
product: r.product, quantity: r.qty, unit_price: r.price,
source_unit: r.unitId || undefined,
})),
}
submitting.value = true
try {
const bill = await api.post('/sales/bills/create-bill/', payload)
try {
await api.post(`/sales/bills/${bill.id}/confirm/`, { force: false })
ElMessage.success(`${bill.bill_no || '销售单'} 已过账`)
} catch (err) {
const d = err.response?.data || {}
if (err.response?.status === 402 || d.code === 'credit_limit_exceeded') {
try {
await ElMessageBox.confirm(
`${d.detail}。是否强制过账(将记录超限预警)?`,
'信用额度超限', {
type: 'error', confirmButtonText: '强制过账', cancelButtonText: '取消',
},
)
} catch (e) { await loadDebts(); return }
await api.post(`/sales/bills/${bill.id}/confirm/`, { force: true })
ElMessage.success(`${bill.bill_no || '销售单'} 已强制过账`)
} else {
ElMessage.warning(`草稿已保存(${bill.bill_no || ''}),过账失败:${d.detail || '库存不足/其他'}`)
}
}
order.value = []
form.round_to = ''
await loadDebts()
} catch (e) {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
}
// ---- 查欠款 ----
const debts = ref([])
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),
)
async function loadDebts() {
debtsLoading.value = true
try {
const params = { page_size: 50 }
if (debtCustomer.value) params.customer = debtCustomer.value
// 后端 Receivables 按 search/status 过滤,无 customer 精确过滤时前端按客户筛
const data = await api.get('/finance/receivables/', { params })
let rows = data.results || []
// 只要未结清
rows = rows.filter((r) => r.status === 'open' || r.status === 'partial')
if (debtCustomer.value) rows = rows.filter((r) => r.customer === debtCustomer.value)
debts.value = rows
} catch (e) {
debts.value = []
} finally {
debtsLoading.value = false
}
}
// ---- 收款登记 ----
const receiptRef = ref(null)
const receiptSaving = ref(false)
const receipt = reactive({
customer: null, bill_no: '', bill_date: '', amount: null, method: '', remark: '',
})
const receiptRules = {
customer: [{ required: true, message: '请选择客户', trigger: 'change' }],
bill_no: [{ required: true, message: '请填写收款单号', trigger: 'blur' }],
bill_date: [{ required: true, message: '请选择收款日期', trigger: 'change' }],
amount: [
{ required: true, message: '请输入收款金额', trigger: 'blur' },
{ validator: (r, v, cb) => (Number(v) > 0 ? cb() : cb(new Error('收款金额必须大于 0'))), trigger: 'blur' },
],
}
function today() {
return new Date().toISOString().slice(0, 10)
}
async function saveReceipt() {
const ok = await receiptRef.value?.validate().catch(() => false)
if (!ok) return
receiptSaving.value = true
try {
const payload = {
customer: receipt.customer,
bill_no: receipt.bill_no.trim(),
bill_date: receipt.bill_date,
amount: String(receipt.amount),
method: receipt.method || '',
remark: receipt.remark || '',
}
await api.post('/finance/receipts/', payload)
ElMessage.success('收款单已登记')
receipt.bill_no = ''
receipt.amount = null
receipt.remark = ''
await loadDebts()
} catch (e) {
const d = e.response?.data || {}
const first = d.customer || d.bill_no || d.bill_date || d.amount || d.detail
if (first) ElMessage.error(`登记失败:${Array.isArray(first) ? first[0] : first}`)
} finally {
receiptSaving.value = false
}
}
async function loadRefs() {
const [c, w, p] = await Promise.all([
api.get('/partner/customers/', { params: { page_size: 200 } }),
api.get('/inventory/warehouses/', { params: { page_size: 200 } }),
api.get('/catalog/products/', { params: { page_size: 200 } }),
])
customers.value = c.results || []
warehouses.value = w.results || []
products.value = p.results || []
visibleProducts.value = products.value
if (!form.warehouse && warehouses.value.length) {
form.warehouse = warehouses.value[0].id
}
if (!receipt.bill_date) receipt.bill_date = today()
}
onMounted(async () => {
if (token.value) {
try {
await loadRefs()
await loadDebts()
} catch (e) {
// token 失效由拦截器踢回登录
}
} else if (!receipt.bill_date) {
receipt.bill_date = today()
}
})
</script>
<style scoped>
.sm-wrap { max-width: 560px; margin: 0 auto; padding: 12px; }
.sm-login { padding-top: 40px; }
.sm-login h2 { margin: 0 0 6px; font-size: 22px; }
.sm-sub { color: #909399; font-size: 13px; margin: 0 0 18px; }
.sm-head {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 10px; border-bottom: 1px solid #ebeef5; margin-bottom: 10px;
}
.sm-shop { font-size: 17px; font-weight: 600; }
.sm-search { margin: 10px 0; }
.sm-list { margin-top: 10px; }
.sm-item, .sm-debt {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 12px 10px; border: 1px solid #ebeef5;
border-radius: 8px; margin-bottom: 10px; background: #fff;
}
.sm-item-main { flex: 1; min-width: 0; }
.sm-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.sm-meta { color: #909399; font-size: 12px; margin-bottom: 4px; }
.sm-price { color: #d4380d; font-weight: 700; font-size: 15px; }
.sm-price-unit { color: #909399; font-size: 12px; font-weight: 400; }
.sm-tax, .sm-tax-total { color: #909399; font-size: 12px; }
.sm-item-ops { display: flex; align-items: center; gap: 6px; }
.sm-order { margin-top: 12px; border-top: 1px solid #ebeef5; padding-top: 10px; }
.sm-order-title { font-weight: 600; margin-bottom: 6px; }
.sm-order-row {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 6px 0; font-size: 13px;
}
.sm-order-name { flex: 1; min-width: 0; }
.sm-totals {
display: flex; gap: 10px; justify-content: flex-end; align-items: center;
padding: 8px 0; font-size: 13px; flex-wrap: wrap;
}
.sm-net { font-size: 16px; color: #d4380d; }
.sm-round { color: #e6a23c; }
.sm-debt-sum { padding: 8px 2px; font-size: 14px; }
.sm-debt-sum b { color: #d4380d; font-size: 17px; }
.credit-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; background: #fafbfc;
border: 1px solid #e4e7ed; border-radius: 6px; margin-bottom: 6px;
}
.credit-label { font-size: 12px; color: #606266; white-space: nowrap; }
@media (max-width: 480px) {
.sm-item { flex-direction: column; align-items: stretch; }
.sm-item-ops { justify-content: flex-end; }
}
</style>
+2 -1
View File
@@ -5,6 +5,7 @@ const routes = [
{ path: '/login', name: 'login', component: () => import('../pages/Login.vue') },
{ path: '/register', name: 'register', component: () => import('../pages/Register.vue') },
{ path: '/storefront', name: 'storefront', component: () => import('../pages/Storefront.vue'), meta: { title: '订货商城' } },
{ path: '/salesman', name: 'salesman', component: () => import('../pages/Salesman.vue'), meta: { title: '业务员开单' } },
{ path: '/storefront-admin', name: 'storefrontAdmin', component: () => import('../pages/StorefrontAdmin.vue'), meta: { title: '商城订单' } },
{
path: '/',
@@ -40,7 +41,7 @@ const routes = [
const router = createRouter({ history: createWebHashHistory(), routes })
const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront'])
const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront', 'salesman'])
router.beforeEach((to) => {
const token = localStorage.getItem('token')