feat(收尾): 附录A#10联系人CRUD补齐(serializer可写FK+前端dialog+test_contact_crud) / D2真机重跑全绿 / 后端512+PG4
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user