feat: 补齐消毒、种源与二维码身份链
This commit is contained in:
@@ -64,6 +64,7 @@
|
|||||||
|
|
||||||
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
- 蚕匾(`trays`)、蚕种批次(`batches`,含品种/蚕种来源/批次号/检疫证明/龄期/入房/上蔟)、饲养记录(`rearing_records`)三表 + CRUD
|
||||||
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
- Web「批次管理」页(批次 + 蚕匾 + 饲养记录);小程序蚕房详情展示蚕匾与当前批次
|
||||||
|
- 生物安全:种源与检疫链、消毒计划/执行、批次/蚕匾/样本二维码身份;小程序可扫码并现场提交消毒执行
|
||||||
|
|
||||||
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { get, post } from './request';
|
||||||
|
|
||||||
|
export interface QRResolveResult {
|
||||||
|
entityType: string;
|
||||||
|
publicId: string;
|
||||||
|
entity: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
sampleNo?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisinfectionRecord {
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
kind: 'plan' | 'execution';
|
||||||
|
agent: string;
|
||||||
|
concentration: string;
|
||||||
|
amount?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveQR = (payload: string) =>
|
||||||
|
post<QRResolveResult>('/biosecurity/qr/resolve', { payload });
|
||||||
|
|
||||||
|
export const createDisinfectionRecord = (data: DisinfectionRecord) =>
|
||||||
|
post('/biosecurity/disinfection-records', data as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
|
export const listDisinfectionRecords = (params?: any) =>
|
||||||
|
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
|
||||||
@@ -16,6 +16,7 @@ export default defineAppConfig({
|
|||||||
'pages/inspection/index',
|
'pages/inspection/index',
|
||||||
'pages/notification/index',
|
'pages/notification/index',
|
||||||
'pages/lamp/index',
|
'pages/lamp/index',
|
||||||
|
'pages/biosecurity/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'dark',
|
backgroundTextStyle: 'dark',
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #4e5969;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
height: 76px;
|
||||||
|
background: #f2f3f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnPrimary {
|
||||||
|
margin-top: 24px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityBox {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityType {
|
||||||
|
display: block;
|
||||||
|
color: #4e5969;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entityName {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button, Input, Text, View } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import styles from './index.module.scss';
|
||||||
|
import { createDisinfectionRecord, resolveQR, type QRResolveResult } from '@/api/biosecurity';
|
||||||
|
|
||||||
|
const BiosecurityPage: React.FC = () => {
|
||||||
|
const [entity, setEntity] = useState<QRResolveResult | null>(null);
|
||||||
|
const [agent, setAgent] = useState('');
|
||||||
|
const [concentration, setConcentration] = useState('');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
try {
|
||||||
|
const res = await Taro.scanCode({ scanType: ['qrCode'] });
|
||||||
|
if (!res.result) throw new Error('未识别到二维码');
|
||||||
|
const data = await resolveQR(res.result);
|
||||||
|
setEntity(data);
|
||||||
|
Taro.showToast({ title: '扫码成功', icon: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: err instanceof Error ? err.message : '扫码失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!entity) {
|
||||||
|
Taro.showToast({ title: '请先扫码', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!agent || !concentration) {
|
||||||
|
Taro.showToast({ title: '请填写药剂和浓度', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createDisinfectionRecord({
|
||||||
|
kind: 'execution',
|
||||||
|
roomId: entity.entity.roomId,
|
||||||
|
batchId: entity.entityType === 'batch' ? entity.entity.id : entity.entity.batchId,
|
||||||
|
agent,
|
||||||
|
concentration,
|
||||||
|
amount: amount || undefined,
|
||||||
|
note: note || undefined,
|
||||||
|
});
|
||||||
|
Taro.showToast({ title: '消毒记录已提交', icon: 'success' });
|
||||||
|
setAgent('');
|
||||||
|
setConcentration('');
|
||||||
|
setAmount('');
|
||||||
|
setNote('');
|
||||||
|
} catch (err) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: err instanceof Error ? err.message : '提交失败',
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={styles.page}>
|
||||||
|
<View className={styles.card}>
|
||||||
|
<Text className={styles.title}>二维码身份</Text>
|
||||||
|
<Button className={styles.btnPrimary} onClick={handleScan}>
|
||||||
|
扫描蚕房/批次/样本二维码
|
||||||
|
</Button>
|
||||||
|
{entity ? (
|
||||||
|
<View className={styles.entityBox}>
|
||||||
|
<Text className={styles.entityType}>类型:{entity.entityType}</Text>
|
||||||
|
<Text className={styles.entityName}>
|
||||||
|
{entity.entity.name || entity.entity.sampleNo || entity.entity.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{entity ? (
|
||||||
|
<View className={styles.card}>
|
||||||
|
<Text className={styles.title}>现场消毒执行</Text>
|
||||||
|
<Text className={styles.label}>消毒药剂</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={agent}
|
||||||
|
placeholder="例如:漂白粉"
|
||||||
|
onInput={(e) => setAgent(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>浓度</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={concentration}
|
||||||
|
placeholder="例如:1%"
|
||||||
|
onInput={(e) => setConcentration(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>用量</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={amount}
|
||||||
|
placeholder="可选"
|
||||||
|
onInput={(e) => setAmount(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Text className={styles.label}>备注</Text>
|
||||||
|
<Input
|
||||||
|
className={styles.input}
|
||||||
|
value={note}
|
||||||
|
placeholder="可选"
|
||||||
|
onInput={(e) => setNote(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.btnPrimary}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
提交消毒记录
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BiosecurityPage;
|
||||||
@@ -59,6 +59,9 @@ const SettingsPage: React.FC = () => {
|
|||||||
case 'lamp':
|
case 'lamp':
|
||||||
Taro.navigateTo({ url: '/pages/lamp/index' });
|
Taro.navigateTo({ url: '/pages/lamp/index' });
|
||||||
break;
|
break;
|
||||||
|
case 'biosecurity':
|
||||||
|
Taro.navigateTo({ url: '/pages/biosecurity/index' });
|
||||||
|
break;
|
||||||
case 'about':
|
case 'about':
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -190,6 +193,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
||||||
<Text className={styles.menuArrow}>›</Text>
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className={styles.menuItem} onClick={() => handleMenuTap('biosecurity')}>
|
||||||
|
<Text className={styles.menuIcon}>🛡️</Text>
|
||||||
|
<Text className={styles.menuLabel}>生物安全</Text>
|
||||||
|
<Text className={styles.menuArrow}>›</Text>
|
||||||
|
</View>
|
||||||
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
||||||
<Text className={styles.menuIcon}>🔗</Text>
|
<Text className={styles.menuIcon}>🔗</Text>
|
||||||
<Text className={styles.menuLabel}>连接状态</Text>
|
<Text className={styles.menuLabel}>连接状态</Text>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ func main() {
|
|||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
handler.RegisterDetectionTaskRoutes(api, db)
|
handler.RegisterDetectionTaskRoutes(api, db)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
|
handler.RegisterBiosecurityRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
handler.RegisterDetectionMethodRoutes(api, db)
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
handler.RegisterTraceRoutes(api, db)
|
handler.RegisterTraceRoutes(api, db)
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.DetectionTask{},
|
&model.DetectionTask{},
|
||||||
&model.Sample{},
|
&model.Sample{},
|
||||||
&model.DiseaseEvent{},
|
&model.DiseaseEvent{},
|
||||||
|
&model.SeedSource{},
|
||||||
|
&model.DisinfectionRecord{},
|
||||||
|
&model.IdentityLink{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "5"
|
const CurrentSchemaVersion = "6"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -112,4 +112,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if err != nil || next != 5 {
|
if err != nil || next != 5 {
|
||||||
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
|
t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err)
|
||||||
}
|
}
|
||||||
|
next, err = driver.Next(next)
|
||||||
|
if err != nil || next != 6 {
|
||||||
|
t.Fatalf("expected biosecurity migration version 6, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"silk-server-go/internal/middleware"
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterBiosecurityRoutes 注册种源、消毒和二维码身份路由。
|
||||||
|
func RegisterBiosecurityRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||||
|
read := middleware.RequirePermission(db, "biosecurity:read")
|
||||||
|
write := middleware.RequirePermission(db, "biosecurity:write")
|
||||||
|
rg.GET("/biosecurity/seed-sources", read, listSeedSources(db))
|
||||||
|
rg.POST("/biosecurity/seed-sources", write, createSeedSource(db))
|
||||||
|
rg.PATCH("/biosecurity/seed-sources/:id", write, updateSeedSource(db))
|
||||||
|
rg.GET("/biosecurity/disinfection-records", read, listDisinfectionRecords(db))
|
||||||
|
rg.POST("/biosecurity/disinfection-records", write, createDisinfectionRecord(db))
|
||||||
|
rg.PATCH("/biosecurity/disinfection-records/:id", write, updateDisinfectionRecord(db))
|
||||||
|
rg.POST("/biosecurity/qr", write, issueQR(db))
|
||||||
|
rg.POST("/biosecurity/qr/resolve", read, resolveQR(db))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSeedSources(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.SeedSource{})
|
||||||
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
|
q = q.Where("batch_id = ?", batch)
|
||||||
|
}
|
||||||
|
var list []model.SeedSource
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
|
Supplier string `json:"supplier"`
|
||||||
|
SeedBatchNo string `json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `json:"quarantineNo"`
|
||||||
|
Variety *string `json:"variety"`
|
||||||
|
CertificateURL *string `json:"certificateUrl"`
|
||||||
|
EntryAt *time.Time `json:"entryAt"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range []*string{body.BatchID, body.ParentID} {
|
||||||
|
if id != nil && !isUUID(*id) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
source := model.SeedSource{
|
||||||
|
PublicID: randomPublicID(),
|
||||||
|
BatchID: body.BatchID,
|
||||||
|
ParentID: body.ParentID,
|
||||||
|
Supplier: body.Supplier,
|
||||||
|
SeedBatchNo: body.SeedBatchNo,
|
||||||
|
QuarantineNo: body.QuarantineNo,
|
||||||
|
Variety: body.Variety,
|
||||||
|
CertificateURL: body.CertificateURL,
|
||||||
|
EntryAt: body.EntryAt,
|
||||||
|
Note: body.Note,
|
||||||
|
CreatedBy: currentUserID(c),
|
||||||
|
}
|
||||||
|
if err := model.ValidateSeedSource(source); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if source.ParentID != nil {
|
||||||
|
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *source.ParentID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Create(&source).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建种源失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
linkSeedSourceToBatch(db, source)
|
||||||
|
c.JSON(http.StatusCreated, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSeedSource(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var source model.SeedSource
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&source).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "seed source not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
BatchID *string `json:"batchId"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
|
Supplier *string `json:"supplier"`
|
||||||
|
SeedBatchNo *string `json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `json:"quarantineNo"`
|
||||||
|
Variety *string `json:"variety"`
|
||||||
|
CertificateURL *string `json:"certificateUrl"`
|
||||||
|
EntryAt *time.Time `json:"entryAt"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if body.BatchID != nil {
|
||||||
|
updates["batch_id"] = *body.BatchID
|
||||||
|
}
|
||||||
|
if body.ParentID != nil {
|
||||||
|
if *body.ParentID != "" {
|
||||||
|
if err := model.SeedSourceCycleError(seedParentChain(db), source.ID, *body.ParentID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["parent_id"] = *body.ParentID
|
||||||
|
} else {
|
||||||
|
updates["parent_id"] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.Supplier != nil {
|
||||||
|
updates["supplier"] = *body.Supplier
|
||||||
|
}
|
||||||
|
if body.SeedBatchNo != nil {
|
||||||
|
updates["seed_batch_no"] = *body.SeedBatchNo
|
||||||
|
}
|
||||||
|
if body.QuarantineNo != nil {
|
||||||
|
updates["quarantine_no"] = *body.QuarantineNo
|
||||||
|
}
|
||||||
|
if body.Variety != nil {
|
||||||
|
updates["variety"] = *body.Variety
|
||||||
|
}
|
||||||
|
if body.CertificateURL != nil {
|
||||||
|
updates["certificate_url"] = *body.CertificateURL
|
||||||
|
}
|
||||||
|
if body.EntryAt != nil {
|
||||||
|
updates["entry_at"] = *body.EntryAt
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.SeedSource{}).Where("id = ?", source.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新种源失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", source.ID).First(&source)
|
||||||
|
if body.BatchID != nil {
|
||||||
|
linkSeedSourceToBatch(db, source)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDisinfectionRecords(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
q := db.Model(&model.DisinfectionRecord{})
|
||||||
|
if room := c.Query("roomId"); room != "" {
|
||||||
|
q = q.Where("room_id = ?", room)
|
||||||
|
}
|
||||||
|
if batch := c.Query("batchId"); batch != "" {
|
||||||
|
q = q.Where("batch_id = ?", batch)
|
||||||
|
}
|
||||||
|
if kind := c.Query("kind"); kind != "" {
|
||||||
|
q = q.Where("kind = ?", kind)
|
||||||
|
}
|
||||||
|
var list []model.DisinfectionRecord
|
||||||
|
q.Order("created_at DESC").Limit(200).Find(&list)
|
||||||
|
c.JSON(http.StatusOK, list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body model.DisinfectionRecord
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.ID = ""
|
||||||
|
body.CreatedBy = currentUserID(c)
|
||||||
|
if err := model.ValidateDisinfectionRecord(body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if body.Kind == "plan" && body.PlannedAt == nil {
|
||||||
|
body.PlannedAt = &now
|
||||||
|
}
|
||||||
|
if body.Kind == "execution" {
|
||||||
|
body.ExecutorID = currentUserID(c)
|
||||||
|
if body.ExecutedAt == nil {
|
||||||
|
body.ExecutedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body.RoomID != nil && !isUUID(*body.RoomID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "roomId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.BatchID != nil && !isUUID(*body.BatchID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Create(&body).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "创建消毒记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisinfectionRecord(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var record model.DisinfectionRecord
|
||||||
|
if db.Where("id = ?", c.Param("id")).First(&record).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "disinfection record not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ExecutedAt *time.Time `json:"executedAt"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewedAt"`
|
||||||
|
ReviewerID *string `json:"reviewerId"`
|
||||||
|
PhotoURL *string `json:"photoUrl"`
|
||||||
|
Note *string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
now := time.Now()
|
||||||
|
if body.ExecutedAt != nil {
|
||||||
|
updates["executed_at"] = now
|
||||||
|
updates["executor_id"] = currentUserID(c)
|
||||||
|
}
|
||||||
|
if body.ReviewedAt != nil {
|
||||||
|
updates["reviewed_at"] = *body.ReviewedAt
|
||||||
|
}
|
||||||
|
if body.ReviewerID != nil {
|
||||||
|
updates["reviewer_id"] = *body.ReviewerID
|
||||||
|
}
|
||||||
|
if body.PhotoURL != nil {
|
||||||
|
updates["photo_url"] = *body.PhotoURL
|
||||||
|
}
|
||||||
|
if body.Note != nil {
|
||||||
|
updates["note"] = *body.Note
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := db.Model(&model.DisinfectionRecord{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "更新消毒记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.Where("id = ?", record.ID).First(&record)
|
||||||
|
c.JSON(http.StatusOK, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueQR(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
EntityType string `json:"entityType"`
|
||||||
|
EntityID string `json:"entityId"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.ValidQRIdentityType(body.EntityType) || !isUUID(body.EntityID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "entityType/entityId 无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !entityExists(db, body.EntityType, body.EntityID) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var link model.IdentityLink
|
||||||
|
if db.Where("entity_type = ? AND entity_id = ?", body.EntityType, body.EntityID).First(&link).Error != nil {
|
||||||
|
link = model.IdentityLink{PublicID: randomPublicID(), EntityType: body.EntityType, EntityID: body.EntityID, Version: model.QRVersion}
|
||||||
|
if err := db.Create(&link).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建二维码失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"publicId": link.PublicID,
|
||||||
|
"entityType": link.EntityType,
|
||||||
|
"entityId": link.EntityID,
|
||||||
|
"payload": model.EncodeQRPayload(link.EntityType, link.PublicID, link.Version),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveQR(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entityType, publicID, version, err := model.ParseQRPayload(body.Payload)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var link model.IdentityLink
|
||||||
|
if db.Where("public_id = ? AND entity_type = ?", publicID, entityType).First(&link).Error != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码身份不存在或已失效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if link.Version != version {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "二维码版本不匹配"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary, ok := entitySummary(db, link.EntityType, link.EntityID)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "二维码关联实体不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"entityType": link.EntityType,
|
||||||
|
"publicId": link.PublicID,
|
||||||
|
"entity": summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedParentChain(db *gorm.DB) map[string]string {
|
||||||
|
var rows []struct {
|
||||||
|
ID string `gorm:"column:id"`
|
||||||
|
ParentID *string `gorm:"column:parent_id"`
|
||||||
|
}
|
||||||
|
db.Table("seed_sources").Select("id, parent_id").Scan(&rows)
|
||||||
|
chain := make(map[string]string, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ParentID != nil {
|
||||||
|
chain[row.ID] = *row.ParentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkSeedSourceToBatch(db *gorm.DB, source model.SeedSource) {
|
||||||
|
if source.BatchID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = db.Model(&model.Batch{}).Where("id = ?", *source.BatchID).Update("seed_source_id", source.ID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityExists(db *gorm.DB, entityType, entityID string) bool {
|
||||||
|
switch entityType {
|
||||||
|
case "batch":
|
||||||
|
var batch model.Batch
|
||||||
|
return db.Where("id = ?", entityID).First(&batch).Error == nil
|
||||||
|
case "tray":
|
||||||
|
var tray model.Tray
|
||||||
|
return db.Where("id = ?", entityID).First(&tray).Error == nil
|
||||||
|
case "sample":
|
||||||
|
var sample model.Sample
|
||||||
|
return db.Where("id = ?", entityID).First(&sample).Error == nil
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entitySummary(db *gorm.DB, entityType, entityID string) (map[string]interface{}, bool) {
|
||||||
|
switch entityType {
|
||||||
|
case "batch":
|
||||||
|
var batch model.Batch
|
||||||
|
if db.Where("id = ?", entityID).First(&batch).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": batch.ID, "name": batch.Name, "roomId": batch.RoomID}, true
|
||||||
|
case "tray":
|
||||||
|
var tray model.Tray
|
||||||
|
if db.Where("id = ?", entityID).First(&tray).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": tray.ID, "name": tray.Name, "roomId": tray.RoomID}, true
|
||||||
|
case "sample":
|
||||||
|
var sample model.Sample
|
||||||
|
if db.Where("id = ?", entityID).First(&sample).Error != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return gin.H{"id": sample.ID, "sampleNo": sample.SampleNo, "detectionTaskId": sample.DetectionTaskID, "state": sample.State}, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPublicID() string {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"silk-server-go/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQRPayloadRoundTripAndTamper(t *testing.T) {
|
||||||
|
payload := model.EncodeQRPayload("batch", "public-abc", model.QRVersion)
|
||||||
|
entityType, publicID, version, err := model.ParseQRPayload(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if entityType != "batch" || publicID != "public-abc" || version != model.QRVersion {
|
||||||
|
t.Fatalf("payload = %s/%s/%d", entityType, publicID, version)
|
||||||
|
}
|
||||||
|
if _, _, _, err := model.ParseQRPayload("batch:public-abc:1"); err == nil {
|
||||||
|
t.Fatal("tampered format should fail")
|
||||||
|
}
|
||||||
|
if _, _, _, err := model.ParseQRPayload(model.EncodeQRPayload("batch", "public-abc", 2)); err != nil {
|
||||||
|
t.Fatal("different version should parse but must be rejected by resolver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSourceCycleRejected(t *testing.T) {
|
||||||
|
chain := map[string]string{
|
||||||
|
"a": "b",
|
||||||
|
"b": "c",
|
||||||
|
"c": "a",
|
||||||
|
}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "d", "a"); err == nil {
|
||||||
|
t.Fatal("cycle should be rejected")
|
||||||
|
}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "a", "a"); err == nil {
|
||||||
|
t.Fatal("self reference should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedSourceCycleAllowsAcyclic(t *testing.T) {
|
||||||
|
chain := map[string]string{"a": "b", "b": "c"}
|
||||||
|
if err := model.SeedSourceCycleError(chain, "d", "a"); err != nil {
|
||||||
|
t.Fatalf("acyclic chain should pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisinfectionRequiredFields(t *testing.T) {
|
||||||
|
record := model.DisinfectionRecord{Kind: "execution", Agent: "漂白粉", Concentration: "1%"}
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err != nil {
|
||||||
|
t.Fatalf("valid record should pass: %v", err)
|
||||||
|
}
|
||||||
|
record.Agent = ""
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("missing agent should fail")
|
||||||
|
}
|
||||||
|
record.Agent = "漂白粉"
|
||||||
|
record.Concentration = ""
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("missing concentration should fail")
|
||||||
|
}
|
||||||
|
record.Concentration = "1%"
|
||||||
|
record.Kind = "unknown"
|
||||||
|
if err := model.ValidateDisinfectionRecord(record); err == nil {
|
||||||
|
t.Fatal("invalid kind should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,6 +262,35 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc {
|
|||||||
"pastCount": pastCount,
|
"pastCount": pastCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生物安全证据:种源与消毒记录
|
||||||
|
bio := map[string]interface{}{
|
||||||
|
"seedSourceCount": int64(0),
|
||||||
|
"disinfectionCount": int64(0),
|
||||||
|
"evidenceSufficient": false,
|
||||||
|
"missing": []string{"种源", "消毒"},
|
||||||
|
}
|
||||||
|
if t.RoomID != nil {
|
||||||
|
var seedCount, disinfectionCount int64
|
||||||
|
db.Table("seed_sources").
|
||||||
|
Joins("JOIN batches ON batches.id = seed_sources.batch_id AND batches.room_id = ?", *t.RoomID).
|
||||||
|
Count(&seedCount)
|
||||||
|
db.Table("disinfection_records").
|
||||||
|
Joins("JOIN batches ON batches.id = disinfection_records.batch_id AND batches.room_id = ?", *t.RoomID).
|
||||||
|
Count(&disinfectionCount)
|
||||||
|
missing := []string{}
|
||||||
|
if seedCount == 0 {
|
||||||
|
missing = append(missing, "种源")
|
||||||
|
}
|
||||||
|
if disinfectionCount == 0 {
|
||||||
|
missing = append(missing, "消毒")
|
||||||
|
}
|
||||||
|
bio["seedSourceCount"] = seedCount
|
||||||
|
bio["disinfectionCount"] = disinfectionCount
|
||||||
|
bio["evidenceSufficient"] = seedCount > 0 && disinfectionCount > 0
|
||||||
|
bio["missing"] = missing
|
||||||
|
}
|
||||||
|
report["biosecurity"] = bio
|
||||||
|
|
||||||
// 传播途径推断
|
// 传播途径推断
|
||||||
mode, source := service.TransmissionInference(t.Disease)
|
mode, source := service.TransmissionInference(t.Disease)
|
||||||
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
report["transmission"] = map[string]interface{}{"mode": mode, "source": source}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const QRVersion = 1
|
||||||
|
|
||||||
|
// IdentityLink 不透明二维码映射,避免把内部 ID 或个人信息放进二维码。
|
||||||
|
type IdentityLink struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||||
|
EntityType string `gorm:"column:entity_type;size:16;index" json:"entityType"`
|
||||||
|
EntityID string `gorm:"column:entity_id;size:128;uniqueIndex" json:"entityId"`
|
||||||
|
Version int `gorm:"default:1" json:"version"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (IdentityLink) TableName() string { return "identity_links" }
|
||||||
|
|
||||||
|
// SeedSource 蚕种来源与检疫链。
|
||||||
|
type SeedSource struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
PublicID string `gorm:"column:public_id;size:64;uniqueIndex" json:"publicId"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
ParentID *string `gorm:"column:parent_id;type:uuid;index" json:"parentId,omitempty"`
|
||||||
|
Supplier string `gorm:"size:128" json:"supplier"`
|
||||||
|
SeedBatchNo string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo"`
|
||||||
|
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"`
|
||||||
|
Variety *string `gorm:"size:64" json:"variety,omitempty"`
|
||||||
|
CertificateURL *string `gorm:"column:certificate_url;size:512" json:"certificateUrl,omitempty"`
|
||||||
|
EntryAt *time.Time `gorm:"column:entry_at;type:timestamptz" json:"entryAt,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SeedSource) TableName() string { return "seed_sources" }
|
||||||
|
|
||||||
|
// DisinfectionRecord 消毒计划与执行记录。
|
||||||
|
type DisinfectionRecord struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
|
Kind string `gorm:"size:16;default:plan" json:"kind"` // plan/execution
|
||||||
|
PlanID *string `gorm:"column:plan_id;type:uuid;index" json:"planId,omitempty"`
|
||||||
|
Agent string `gorm:"size:128" json:"agent"`
|
||||||
|
Concentration string `gorm:"size:64" json:"concentration"`
|
||||||
|
Amount *string `gorm:"size:64" json:"amount,omitempty"`
|
||||||
|
PlannedAt *time.Time `gorm:"column:planned_at;type:timestamptz" json:"plannedAt,omitempty"`
|
||||||
|
ExecutedAt *time.Time `gorm:"column:executed_at;type:timestamptz" json:"executedAt,omitempty"`
|
||||||
|
ExecutorID *string `gorm:"column:executor_id;type:uuid" json:"executorId,omitempty"`
|
||||||
|
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
|
||||||
|
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
|
||||||
|
PhotoURL *string `gorm:"column:photo_url;size:512" json:"photoUrl,omitempty"`
|
||||||
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
|
CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DisinfectionRecord) TableName() string { return "disinfection_records" }
|
||||||
|
|
||||||
|
// EncodeQRPayload 生成不透明二维码载荷。
|
||||||
|
func EncodeQRPayload(entityType, publicID string, version int) string {
|
||||||
|
return fmt.Sprintf("silk:v1:%s:%s:%d", entityType, publicID, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseQRPayload 解析并校验二维码载荷。
|
||||||
|
func ParseQRPayload(payload string) (string, string, int, error) {
|
||||||
|
parts := strings.Split(strings.TrimSpace(payload), ":")
|
||||||
|
if len(parts) != 5 || parts[0] != "silk" || parts[1] != "v1" {
|
||||||
|
return "", "", 0, errors.New("二维码格式不正确")
|
||||||
|
}
|
||||||
|
entityType := parts[2]
|
||||||
|
publicID := parts[3]
|
||||||
|
version, err := strconv.Atoi(parts[4])
|
||||||
|
if err != nil || version <= 0 {
|
||||||
|
return "", "", 0, errors.New("二维码版本不正确")
|
||||||
|
}
|
||||||
|
if !ValidQRIdentityType(entityType) || publicID == "" {
|
||||||
|
return "", "", 0, errors.New("二维码身份无效")
|
||||||
|
}
|
||||||
|
return entityType, publicID, version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidQRIdentityType 当前二维码支持的实体类型。
|
||||||
|
func ValidQRIdentityType(entityType string) bool {
|
||||||
|
switch entityType {
|
||||||
|
case "batch", "tray", "sample":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSeedSource 种源必填项。
|
||||||
|
func ValidateSeedSource(source SeedSource) error {
|
||||||
|
if strings.TrimSpace(source.Supplier) == "" {
|
||||||
|
return errors.New("供应商不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.SeedBatchNo) == "" {
|
||||||
|
return errors.New("蚕种批号不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDisinfectionRecord 消毒必填项。
|
||||||
|
func ValidateDisinfectionRecord(record DisinfectionRecord) error {
|
||||||
|
if record.Kind != "plan" && record.Kind != "execution" {
|
||||||
|
return errors.New("kind 仅支持 plan/execution")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Agent) == "" {
|
||||||
|
return errors.New("消毒药剂不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Concentration) == "" {
|
||||||
|
return errors.New("消毒浓度不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedSourceCycleError 检测种源链是否形成循环。
|
||||||
|
func SeedSourceCycleError(chain map[string]string, id, parentID string) error {
|
||||||
|
seen := map[string]bool{id: true}
|
||||||
|
current := parentID
|
||||||
|
for current != "" {
|
||||||
|
if seen[current] {
|
||||||
|
return errors.New("种源链不能形成循环")
|
||||||
|
}
|
||||||
|
seen[current] = true
|
||||||
|
next := chain[current]
|
||||||
|
if next == current {
|
||||||
|
return errors.New("种源链不能自引用")
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ var AllPermissions = []PermissionDef{
|
|||||||
{"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"},
|
{"consultation:write", "会诊管理", "发起会诊、出具意见与防控方案"},
|
||||||
{"trace:read", "溯源查看", "查看疫病溯源记录与报告"},
|
{"trace:read", "溯源查看", "查看疫病溯源记录与报告"},
|
||||||
{"trace:write", "溯源管理", "发起溯源、执行排查清单与报告"},
|
{"trace:write", "溯源管理", "发起溯源、执行排查清单与报告"},
|
||||||
|
{"biosecurity:read", "生物安全查看", "查看种源、消毒和二维码身份"},
|
||||||
|
{"biosecurity:write", "生物安全管理", "维护种源、消毒记录并签发二维码"},
|
||||||
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
{"user:manage", "用户管理", "管理用户、角色和权限"},
|
||||||
{"audit:read", "审计查看", "查看审计日志"},
|
{"audit:read", "审计查看", "查看审计日志"},
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
"consultation:read", "consultation:write",
|
"consultation:read", "consultation:write",
|
||||||
"trace:read", "trace:write",
|
"trace:read", "trace:write",
|
||||||
|
"biosecurity:read", "biosecurity:write",
|
||||||
"user:manage", "audit:read",
|
"user:manage", "audit:read",
|
||||||
},
|
},
|
||||||
RoleOperator: {
|
RoleOperator: {
|
||||||
@@ -77,6 +80,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consumable:read", "consumable:write",
|
"consumable:read", "consumable:write",
|
||||||
"consultation:read", "consultation:write",
|
"consultation:read", "consultation:write",
|
||||||
"trace:read", "trace:write",
|
"trace:read", "trace:write",
|
||||||
|
"biosecurity:read", "biosecurity:write",
|
||||||
},
|
},
|
||||||
RoleViewer: {
|
RoleViewer: {
|
||||||
"dashboard:view", "room:read", "device:read",
|
"dashboard:view", "room:read", "device:read",
|
||||||
@@ -90,6 +94,7 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consumable:read",
|
"consumable:read",
|
||||||
"consultation:read",
|
"consultation:read",
|
||||||
"trace:read",
|
"trace:read",
|
||||||
|
"biosecurity:read",
|
||||||
},
|
},
|
||||||
RoleFarmer: {
|
RoleFarmer: {
|
||||||
"dashboard:view", "room:read", "device:read", "device:control",
|
"dashboard:view", "room:read", "device:read", "device:control",
|
||||||
@@ -103,5 +108,6 @@ var RolePermissionMap = map[string][]string{
|
|||||||
"consumable:read",
|
"consumable:read",
|
||||||
"consultation:read",
|
"consultation:read",
|
||||||
"trace:read",
|
"trace:read",
|
||||||
|
"biosecurity:read", "biosecurity:write",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,15 @@ type Batch struct {
|
|||||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
RoomID string `gorm:"column:room_id;type:uuid;index" json:"roomId"`
|
RoomID string `gorm:"column:room_id;type:uuid;index" json:"roomId"`
|
||||||
Name string `gorm:"size:64" json:"name"`
|
Name string `gorm:"size:64" json:"name"`
|
||||||
Variety *string `gorm:"size:64" json:"variety,omitempty"` // 品种
|
Variety *string `gorm:"size:64" json:"variety,omitempty"` // 品种
|
||||||
Source *string `gorm:"size:128" json:"source,omitempty"` // 蚕种来源/供应商
|
Source *string `gorm:"size:128" json:"source,omitempty"` // 蚕种来源/供应商
|
||||||
SeedBatchNo *string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo,omitempty"` // 蚕种批次号
|
SeedSourceID *string `gorm:"column:seed_source_id;type:uuid;index" json:"seedSourceId,omitempty"`
|
||||||
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"` // 检疫证明编号
|
SeedBatchNo *string `gorm:"column:seed_batch_no;size:64" json:"seedBatchNo,omitempty"` // 蚕种批次号
|
||||||
Instar *int `gorm:"type:int" json:"instar,omitempty"` // 龄期 1-5
|
QuarantineNo *string `gorm:"column:quarantine_no;size:64" json:"quarantineNo,omitempty"` // 检疫证明编号
|
||||||
|
Instar *int `gorm:"type:int" json:"instar,omitempty"` // 龄期 1-5
|
||||||
EnteredAt *time.Time `gorm:"column:entered_at;type:timestamptz" json:"enteredAt,omitempty"` // 入房时间
|
EnteredAt *time.Time `gorm:"column:entered_at;type:timestamptz" json:"enteredAt,omitempty"` // 入房时间
|
||||||
MountAt *time.Time `gorm:"column:mount_at;type:timestamptz" json:"mountAt,omitempty"` // 上蔟时间
|
MountAt *time.Time `gorm:"column:mount_at;type:timestamptz" json:"mountAt,omitempty"` // 上蔟时间
|
||||||
Status string `gorm:"size:16;default:rearing" json:"status"` // rearing/mounted/finished
|
Status string `gorm:"size:16;default:rearing" json:"status"` // rearing/mounted/finished
|
||||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE batches DROP COLUMN IF EXISTS seed_source_id;
|
||||||
|
DROP TABLE IF EXISTS identity_links CASCADE;
|
||||||
|
DROP TABLE IF EXISTS disinfection_records CASCADE;
|
||||||
|
DROP TABLE IF EXISTS seed_sources CASCADE;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS seed_sources (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_id varchar(64) NOT NULL,
|
||||||
|
batch_id uuid,
|
||||||
|
parent_id uuid,
|
||||||
|
supplier varchar(128) NOT NULL,
|
||||||
|
seed_batch_no varchar(64) NOT NULL,
|
||||||
|
quarantine_no varchar(64),
|
||||||
|
variety varchar(64),
|
||||||
|
certificate_url varchar(512),
|
||||||
|
entry_at timestamptz,
|
||||||
|
note text,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_seed_sources_public_id ON seed_sources (public_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seed_sources_batch_id ON seed_sources (batch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seed_sources_parent_id ON seed_sources (parent_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disinfection_records (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
room_id uuid,
|
||||||
|
batch_id uuid,
|
||||||
|
kind varchar(16) NOT NULL DEFAULT 'plan',
|
||||||
|
plan_id uuid,
|
||||||
|
agent varchar(128) NOT NULL,
|
||||||
|
concentration varchar(64) NOT NULL,
|
||||||
|
amount varchar(64),
|
||||||
|
planned_at timestamptz,
|
||||||
|
executed_at timestamptz,
|
||||||
|
executor_id uuid,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
reviewer_id uuid,
|
||||||
|
photo_url varchar(512),
|
||||||
|
note text,
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disinfection_records_room_id ON disinfection_records (room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disinfection_records_batch_id ON disinfection_records (batch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_disinfection_records_plan_id ON disinfection_records (plan_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS identity_links (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
public_id varchar(64) NOT NULL,
|
||||||
|
entity_type varchar(16) NOT NULL,
|
||||||
|
entity_id varchar(128) NOT NULL,
|
||||||
|
version integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_public_id ON identity_links (public_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_entity ON identity_links (entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_identity_links_entity_type ON identity_links (entity_type);
|
||||||
|
|
||||||
|
ALTER TABLE batches
|
||||||
|
ADD COLUMN IF NOT EXISTS seed_source_id uuid;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_batches_seed_source_id ON batches (seed_source_id);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { get, post, patch } from '../api/http';
|
||||||
|
|
||||||
|
export interface SeedSource {
|
||||||
|
id: string;
|
||||||
|
publicId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
supplier: string;
|
||||||
|
seedBatchNo: string;
|
||||||
|
quarantineNo?: string;
|
||||||
|
variety?: string;
|
||||||
|
certificateUrl?: string;
|
||||||
|
entryAt?: string;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisinfectionRecord {
|
||||||
|
id: string;
|
||||||
|
roomId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
kind: 'plan' | 'execution';
|
||||||
|
planId?: string;
|
||||||
|
agent: string;
|
||||||
|
concentration: string;
|
||||||
|
amount?: string;
|
||||||
|
plannedAt?: string;
|
||||||
|
executedAt?: string;
|
||||||
|
executorId?: string;
|
||||||
|
reviewedAt?: string;
|
||||||
|
reviewerId?: string;
|
||||||
|
photoUrl?: string;
|
||||||
|
note?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRIdentity {
|
||||||
|
publicId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listSeedSources = (params?: any) =>
|
||||||
|
get<SeedSource[]>('/biosecurity/seed-sources', { params });
|
||||||
|
export const createSeedSource = (data: Partial<SeedSource>) =>
|
||||||
|
post<SeedSource>('/biosecurity/seed-sources', data);
|
||||||
|
export const updateSeedSource = (id: string, data: Partial<SeedSource>) =>
|
||||||
|
patch<SeedSource>(`/biosecurity/seed-sources/${id}`, data);
|
||||||
|
|
||||||
|
export const listDisinfectionRecords = (params?: any) =>
|
||||||
|
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
|
||||||
|
export const createDisinfectionRecord = (data: Partial<DisinfectionRecord>) =>
|
||||||
|
post<DisinfectionRecord>('/biosecurity/disinfection-records', data);
|
||||||
|
export const updateDisinfectionRecord = (id: string, data: Partial<DisinfectionRecord>) =>
|
||||||
|
patch<DisinfectionRecord>(`/biosecurity/disinfection-records/${id}`, data);
|
||||||
|
|
||||||
|
export const issueQR = (entityType: string, entityId: string) =>
|
||||||
|
post<QRIdentity>('/biosecurity/qr', { entityType, entityId });
|
||||||
|
export const resolveQR = (payload: string) =>
|
||||||
|
post<{ entityType: string; publicId: string; entity: any }>('/biosecurity/qr/resolve', { payload });
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
CameraOutlined,
|
CameraOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
DeploymentUnitOutlined,
|
DeploymentUnitOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -42,6 +43,7 @@ const menuData = [
|
|||||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||||
{ path: '/detection-tasks', name: '检测任务', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
{ path: '/detection-tasks', name: '检测任务', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||||
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
||||||
|
{ path: '/biosecurity', name: '生物安全', icon: <SafetyCertificateOutlined />, permission: 'biosecurity:read' },
|
||||||
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
||||||
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
||||||
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
|
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, DatePicker, Form, Input, Modal, Select, Space, Tabs, Tag, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {
|
||||||
|
createDisinfectionRecord,
|
||||||
|
createSeedSource,
|
||||||
|
issueQR,
|
||||||
|
listDisinfectionRecords,
|
||||||
|
listSeedSources,
|
||||||
|
resolveQR,
|
||||||
|
updateDisinfectionRecord,
|
||||||
|
type DisinfectionRecord,
|
||||||
|
type SeedSource,
|
||||||
|
} from '../dal/biosecurity';
|
||||||
|
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
|
||||||
|
import { listBatches, type Batch } from '../dal/trayBatch';
|
||||||
|
import { authService } from '../services/auth';
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, string> = {
|
||||||
|
plan: '计划',
|
||||||
|
execution: '执行',
|
||||||
|
};
|
||||||
|
|
||||||
|
const canWrite = () => authService.hasPermission('biosecurity:write');
|
||||||
|
|
||||||
|
export default function BiosecurityPage() {
|
||||||
|
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
|
||||||
|
const [batches, setBatches] = useState<Batch[]>([]);
|
||||||
|
const [seedSources, setSeedSources] = useState<SeedSource[]>([]);
|
||||||
|
const seedAction = useRef<ActionType>();
|
||||||
|
const disinfectionAction = useRef<ActionType>();
|
||||||
|
const [seedOpen, setSeedOpen] = useState(false);
|
||||||
|
const [seedForm] = Form.useForm();
|
||||||
|
const [disinfectionOpen, setDisinfectionOpen] = useState(false);
|
||||||
|
const [disinfectionForm] = Form.useForm();
|
||||||
|
const [qrIssued, setQrIssued] = useState<any>(null);
|
||||||
|
const [qrResolved, setQrResolved] = useState<any>(null);
|
||||||
|
const [qrForm] = Form.useForm();
|
||||||
|
const [resolveForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
listHouses().catch(() => ({ items: [] as SilkwormHouse[] })),
|
||||||
|
listBatches().catch(() => [] as Batch[]),
|
||||||
|
listSeedSources().catch(() => [] as SeedSource[]),
|
||||||
|
]).then(([houseRes, batchRes, seedRes]) => {
|
||||||
|
setRooms(houseRes.items);
|
||||||
|
setBatches(batchRes);
|
||||||
|
setSeedSources(seedRes);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
|
||||||
|
const batchName = (id?: string) => batches.find((b) => b.id === id)?.name || id || '-';
|
||||||
|
|
||||||
|
const seedColumns: ProColumns<SeedSource>[] = [
|
||||||
|
{ title: '供应商', dataIndex: 'supplier' },
|
||||||
|
{ title: '批号', dataIndex: 'seedBatchNo' },
|
||||||
|
{ title: '检疫证', dataIndex: 'quarantineNo', search: false, render: (_, r) => r.quarantineNo || '-' },
|
||||||
|
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
|
||||||
|
{ title: '上链来源', dataIndex: 'parentId', search: false, render: (_, r) => r.parentId || '-' },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const disinfectionColumns: ProColumns<DisinfectionRecord>[] = [
|
||||||
|
{ title: '类型', dataIndex: 'kind', valueEnum: Object.fromEntries(Object.entries(KIND_LABELS).map(([k, v]) => [k, { text: v }])) },
|
||||||
|
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
|
||||||
|
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
|
||||||
|
{ title: '药剂', dataIndex: 'agent' },
|
||||||
|
{ title: '浓度', dataIndex: 'concentration' },
|
||||||
|
{ title: '计划时间', dataIndex: 'plannedAt', search: false, render: (_, r) => (r.plannedAt ? dayjs(r.plannedAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
{ title: '执行时间', dataIndex: 'executedAt', search: false, render: (_, r) => (r.executedAt ? dayjs(r.executedAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
valueType: 'option',
|
||||||
|
render: (_, r) => [
|
||||||
|
...(canWrite() && !r.executedAt
|
||||||
|
? [
|
||||||
|
<a key="execute" onClick={async () => {
|
||||||
|
await updateDisinfectionRecord(r.id, { executedAt: new Date().toISOString() });
|
||||||
|
message.success('已记录执行');
|
||||||
|
disinfectionAction.current?.reload();
|
||||||
|
}}>标记执行</a>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'seeds',
|
||||||
|
label: '种源与检疫链',
|
||||||
|
children: (
|
||||||
|
<ProTable<SeedSource>
|
||||||
|
actionRef={seedAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={seedColumns}
|
||||||
|
search={false}
|
||||||
|
request={async () => {
|
||||||
|
const res = await listSeedSources();
|
||||||
|
setSeedSources(res);
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() =>
|
||||||
|
canWrite()
|
||||||
|
? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { seedForm.resetFields(); setSeedOpen(true); }}>新增种源</Button>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'disinfection',
|
||||||
|
label: '消毒记录',
|
||||||
|
children: (
|
||||||
|
<ProTable<DisinfectionRecord>
|
||||||
|
actionRef={disinfectionAction}
|
||||||
|
rowKey="id"
|
||||||
|
columns={disinfectionColumns}
|
||||||
|
search={false}
|
||||||
|
request={async (params) => {
|
||||||
|
const res = await listDisinfectionRecords({ kind: params.kind });
|
||||||
|
return { data: res, total: res.length, success: true };
|
||||||
|
}}
|
||||||
|
toolBarRender={() =>
|
||||||
|
canWrite()
|
||||||
|
? [
|
||||||
|
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { disinfectionForm.resetFields(); setDisinfectionOpen(true); }}>新增消毒记录</Button>,
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qr',
|
||||||
|
label: '二维码身份',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 720 }}>
|
||||||
|
<Form form={qrForm} layout="inline" onFinish={async (v) => {
|
||||||
|
setQrIssued(await issueQR(v.entityType, v.entityId));
|
||||||
|
}}>
|
||||||
|
<Form.Item name="entityType" initialValue="batch" rules={[{ required: true }]}>
|
||||||
|
<Select style={{ width: 140 }} options={[
|
||||||
|
{ value: 'batch', label: '批次' },
|
||||||
|
{ value: 'tray', label: '蚕匾' },
|
||||||
|
{ value: 'sample', label: '样本' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="entityId" rules={[{ required: true, message: '请输入实体 ID' }]}>
|
||||||
|
<Input placeholder="实体 ID" style={{ width: 260 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">签发二维码</Button>
|
||||||
|
</Form>
|
||||||
|
{qrIssued ? (
|
||||||
|
<div>
|
||||||
|
<Tag color="green">已签发</Tag>
|
||||||
|
<pre>{qrIssued.payload}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Form form={resolveForm} layout="inline" onFinish={async (v) => {
|
||||||
|
setQrResolved(await resolveQR(v.payload));
|
||||||
|
}}>
|
||||||
|
<Form.Item name="payload" rules={[{ required: true, message: '请输入二维码内容' }]} style={{ minWidth: 420 }}>
|
||||||
|
<Input placeholder="粘贴二维码内容" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button htmlType="submit">解析二维码</Button>
|
||||||
|
</Form>
|
||||||
|
{qrResolved ? (
|
||||||
|
<div>
|
||||||
|
<Tag color="blue">解析结果</Tag>
|
||||||
|
<pre>{JSON.stringify(qrResolved, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新增种源"
|
||||||
|
open={seedOpen}
|
||||||
|
onCancel={() => setSeedOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await seedForm.validateFields();
|
||||||
|
await createSeedSource({ ...v, entryAt: v.entryAt?.toISOString() });
|
||||||
|
message.success('种源已创建');
|
||||||
|
setSeedOpen(false);
|
||||||
|
seedAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={seedForm} layout="vertical">
|
||||||
|
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="蚕种批号" name="seedBatchNo" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="关联批次" name="batchId">
|
||||||
|
<Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="上链来源" name="parentId">
|
||||||
|
<Select allowClear options={seedSources.map((s) => ({ value: s.id, label: `${s.supplier} ${s.seedBatchNo}` }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="检疫证号" name="quarantineNo">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="品种" name="variety">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="入场时间" name="entryAt">
|
||||||
|
<DatePicker showTime style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="检疫凭证 URL" name="certificateUrl">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" name="note">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新增消毒记录"
|
||||||
|
open={disinfectionOpen}
|
||||||
|
onCancel={() => setDisinfectionOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
const v = await disinfectionForm.validateFields();
|
||||||
|
await createDisinfectionRecord(v);
|
||||||
|
message.success('消毒记录已创建');
|
||||||
|
setDisinfectionOpen(false);
|
||||||
|
disinfectionAction.current?.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={disinfectionForm} layout="vertical">
|
||||||
|
<Form.Item label="类型" name="kind" initialValue="plan" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="蚕房" name="roomId">
|
||||||
|
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="批次" name="batchId">
|
||||||
|
<Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="消毒药剂" name="agent" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="浓度" name="concentration" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="用量" name="amount">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="备注" name="note">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import Batches from './pages/Batches';
|
|||||||
import LampTests from './pages/LampTests';
|
import LampTests from './pages/LampTests';
|
||||||
import DetectionTasks from './pages/DetectionTasks';
|
import DetectionTasks from './pages/DetectionTasks';
|
||||||
import Consumables from './pages/Consumables';
|
import Consumables from './pages/Consumables';
|
||||||
|
import Biosecurity from './pages/Biosecurity';
|
||||||
import Inspections from './pages/Inspections';
|
import Inspections from './pages/Inspections';
|
||||||
import Consultations from './pages/Consultations';
|
import Consultations from './pages/Consultations';
|
||||||
import Traces from './pages/Traces';
|
import Traces from './pages/Traces';
|
||||||
@@ -59,6 +60,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
||||||
{ path: 'detection-tasks', element: <RequirePermission permission="lamp:read"><DetectionTasks /></RequirePermission> },
|
{ path: 'detection-tasks', element: <RequirePermission permission="lamp:read"><DetectionTasks /></RequirePermission> },
|
||||||
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
||||||
|
{ path: 'biosecurity', element: <RequirePermission permission="biosecurity:read"><Biosecurity /></RequirePermission> },
|
||||||
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
||||||
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
||||||
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
|
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后续工作计划
|
# 后续工作计划
|
||||||
|
|
||||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||||
|
|
||||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
||||||
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
||||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 |
|
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 |
|
||||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
|
||||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
||||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
||||||
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
||||||
|
|||||||
@@ -1027,3 +1027,34 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
|||||||
- 本任务前分支提交为 `a25bc7a`;回滚可还原 Task 9 提交;
|
- 本任务前分支提交为 `a25bc7a`;回滚可还原 Task 9 提交;
|
||||||
- 数据库回滚执行 `000005_detection_disease_events.down.sql`,可删除新表和关联列;已建立 `DiseaseEvent -> TraceRecord` 关联的数据会随回滚断开,回滚前需先备份;
|
- 数据库回滚执行 `000005_detection_disease_events.down.sql`,可删除新表和关联列;已建立 `DiseaseEvent -> TraceRecord` 关联的数据会随回滚断开,回滚前需先备份;
|
||||||
- Web 新页面回滚只需还原路由/菜单/页面文件,旧 LAMP 页面不受影响。
|
- Web 新页面回滚只需还原路由/菜单/页面文件,旧 LAMP 页面不受影响。
|
||||||
|
|
||||||
|
## 2026-08-14 整改 Task 10:补齐消毒、种源与二维码身份链
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 `seed_sources`、`disinfection_records`、`identity_links` 表及 `000006_biosecurity` 迁移;`batches` 增加 `seed_source_id`;
|
||||||
|
- 新增 `biosecurity:read/write` 权限并接入 admin/operator/viewer/farmer 角色;
|
||||||
|
- 种源链支持供应商、蚕种批号、检疫证号、入场时间、凭证 URL 和上链来源,创建/更新时检测循环引用;
|
||||||
|
- 消毒记录支持计划/执行两类,必填药剂和浓度,记录计划时间、执行时间、执行人、复核人和照片 URL;
|
||||||
|
- 二维码载荷只包含 `silk:v1:entityType:publicId:version`,通过 `identity_links` 映射到批次/蚕匾/样本;解析时校验实体存在、版本和权限;
|
||||||
|
- 溯源初报新增生物安全证据汇总,种源或消毒缺失时返回 `missing` 和 `evidenceSufficient=false`;
|
||||||
|
- Web 新增「生物安全」页面;小程序新增扫码与现场消毒执行页。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- 二维码不包含内部 ID、密码或个人信息,使用随机 `publicId` 和数据库映射,避免可猜测身份;
|
||||||
|
- 种源链采用 `parent_id` 表达跨批次关系,并用循环检测阻止错误引用;
|
||||||
|
- 消毒记录使用同一表承载计划和执行,执行记录可关联计划,便于现场录入和复核;
|
||||||
|
- 当前项目还没有组织级 ACL,二维码解析先按实体存在性和 `biosecurity:read` RBAC 控制,组织/房间级对象授权仍待后续任务。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
|
||||||
|
- 新增测试覆盖二维码篡改/版本解析、种源链循环/无环、消毒必填项;
|
||||||
|
- 未部署开发服务器,未执行 `000006` 迁移;未做真实二维码打印与小程序扫码联调。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务前分支提交为 `cc93c9a`;回滚可还原 Task 10 提交;
|
||||||
|
- 数据库回滚执行 `000006_biosecurity.down.sql`,可删除种源、消毒、二维码映射表和 `batches.seed_source_id`;
|
||||||
|
- 页面回滚需同时还原 Web 路由/菜单、小程序页面与设置入口,并移除 `biosecurity` 权限种子。
|
||||||
|
|||||||
Reference in New Issue
Block a user