瀏覽代碼

交易市场平台(供应商)

wwh 1 周之前
父節點
當前提交
76ea2a0991
共有 15 個文件被更改,包括 824 次插入147 次删除
  1. 13 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/BizYakAsset.java
  2. 312 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/dto/OpenYakEntryDto.java
  3. 7 7
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/dto/YakAssetBundleDto.java
  4. 2 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/mapper/BizYakAssetMapper.java
  5. 53 10
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/service/YakAssetBundleSyncTxService.java
  6. 80 31
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/support/ThirdPartyYakClientImpl.java
  7. 214 0
      baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/support/YakEntryOpenApiMapper.java
  8. 11 3
      baqing-admin/src/main/resources/mapper/industryservice/BizYakAssetMapper.xml
  9. 0 78
      baqing-admin/src/main/resources/thirdparty/stub-yak-bundles.json
  10. 25 0
      baqing-admin/src/main/resources/thirdparty/stub-yak-entries.json
  11. 57 0
      baqing-admin/src/test/java/com/ruoyi/web/modules/industryservice/support/YakEntryOpenApiMapperTest.java
  12. 7 4
      doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理功能需求.md
  13. 35 9
      doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理技术方案.md
  14. 5 4
      doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理测试用例.md
  15. 3 1
      sql/biz_yak_asset.sql

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/BizYakAsset.java

@@ -14,6 +14,9 @@ public class BizYakAsset extends BaseEntity
14 14
 
15 15
     private Long id;
16 16
 
17
+    /** 第三方牛只唯一 ID(OpenYakEntryDto.cattleId) */
18
+    private Long externalId;
19
+
17 20
     private String yakNo;
18 21
 
19 22
     private Long pastureId;
@@ -85,6 +88,16 @@ public class BizYakAsset extends BaseEntity
85 88
         this.id = id;
86 89
     }
87 90
 
91
+    public Long getExternalId()
92
+    {
93
+        return externalId;
94
+    }
95
+
96
+    public void setExternalId(Long externalId)
97
+    {
98
+        this.externalId = externalId;
99
+    }
100
+
88 101
     public String getYakNo()
89 102
     {
90 103
         return yakNo;

+ 312 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/dto/OpenYakEntryDto.java

@@ -0,0 +1,312 @@
1
+package com.ruoyi.web.modules.industryservice.domain.dto;
2
+
3
+/**
4
+ * 第三方开放接口「查询入栏建档列表」单条记录 DTO。
5
+ * <p>
6
+ * 响应路径 {@code data.records[]}(分页包装 {@code OpenApiPageResult})。
7
+ * 接口:{@code GET /open-api/v1/farming/entry-filings}。
8
+ * </p>
9
+ *
10
+ * @see com.ruoyi.web.modules.industryservice.domain.BizYakAsset
11
+ */
12
+public class OpenYakEntryDto
13
+{
14
+    /** 档案主键 ID */
15
+    private Long id;
16
+
17
+    /** 所属农场 ID,落库关联 biz_pasture.external_id */
18
+    private Long farmId;
19
+
20
+    /** 农场名称,落库 pasture_name */
21
+    private String farmName;
22
+
23
+    /** 所属圈舍 ID */
24
+    private Long farmEnclosureId;
25
+
26
+    /** 圈舍名称,落库 pen_location */
27
+    private String farmEnclosureName;
28
+
29
+    /** 所属养殖批次 ID */
30
+    private Long batchId;
31
+
32
+    /** 入栏批次周期,落库 batch_no(优先于 batchId) */
33
+    private String entryCycle;
34
+
35
+    /** 农场地理位置,落库 location */
36
+    private String farmLocation;
37
+
38
+    /** 牛只唯一 ID,落库 external_id */
39
+    private Long cattleId;
40
+
41
+    /** 场内牛只编号 */
42
+    private String cattleNo;
43
+
44
+    /** 耳标编号(溯源标识),落库 yak_no(优先于 cattleNo) */
45
+    private String earTagNumber;
46
+
47
+    /** 牛只品种 */
48
+    private String cattleVariety;
49
+
50
+    /** 牛只性别,落库 gender */
51
+    private String cattleSex;
52
+
53
+    /** 牛只来源(自繁/外购),落库 source */
54
+    private String cattleSource;
55
+
56
+    /** 父牛谱系编号,落库 father_yak_no */
57
+    private String fatherNumber;
58
+
59
+    /** 母牛谱系编号,落库 mother_yak_no */
60
+    private String motherNumber;
61
+
62
+    /** 当前体重,落库 entry_weight_kg */
63
+    private Double weight;
64
+
65
+    /** 牛只照片 URL(本期不落库) */
66
+    private String cattlePicture;
67
+
68
+    /** 档案登记人(本期不落库) */
69
+    private String registrant;
70
+
71
+    /** 入栏时间,落库 entry_date */
72
+    private String entryTime;
73
+
74
+    /** 出生日期,落库 birth_date */
75
+    private String birthDate;
76
+
77
+    /** 档案创建时间 */
78
+    private String createTime;
79
+
80
+    /** 备注,落库 status_change_reason */
81
+    private String remark;
82
+
83
+    public Long getId()
84
+    {
85
+        return id;
86
+    }
87
+
88
+    public void setId(Long id)
89
+    {
90
+        this.id = id;
91
+    }
92
+
93
+    public Long getFarmId()
94
+    {
95
+        return farmId;
96
+    }
97
+
98
+    public void setFarmId(Long farmId)
99
+    {
100
+        this.farmId = farmId;
101
+    }
102
+
103
+    public String getFarmName()
104
+    {
105
+        return farmName;
106
+    }
107
+
108
+    public void setFarmName(String farmName)
109
+    {
110
+        this.farmName = farmName;
111
+    }
112
+
113
+    public Long getFarmEnclosureId()
114
+    {
115
+        return farmEnclosureId;
116
+    }
117
+
118
+    public void setFarmEnclosureId(Long farmEnclosureId)
119
+    {
120
+        this.farmEnclosureId = farmEnclosureId;
121
+    }
122
+
123
+    public String getFarmEnclosureName()
124
+    {
125
+        return farmEnclosureName;
126
+    }
127
+
128
+    public void setFarmEnclosureName(String farmEnclosureName)
129
+    {
130
+        this.farmEnclosureName = farmEnclosureName;
131
+    }
132
+
133
+    public Long getBatchId()
134
+    {
135
+        return batchId;
136
+    }
137
+
138
+    public void setBatchId(Long batchId)
139
+    {
140
+        this.batchId = batchId;
141
+    }
142
+
143
+    public String getEntryCycle()
144
+    {
145
+        return entryCycle;
146
+    }
147
+
148
+    public void setEntryCycle(String entryCycle)
149
+    {
150
+        this.entryCycle = entryCycle;
151
+    }
152
+
153
+    public String getFarmLocation()
154
+    {
155
+        return farmLocation;
156
+    }
157
+
158
+    public void setFarmLocation(String farmLocation)
159
+    {
160
+        this.farmLocation = farmLocation;
161
+    }
162
+
163
+    public Long getCattleId()
164
+    {
165
+        return cattleId;
166
+    }
167
+
168
+    public void setCattleId(Long cattleId)
169
+    {
170
+        this.cattleId = cattleId;
171
+    }
172
+
173
+    public String getCattleNo()
174
+    {
175
+        return cattleNo;
176
+    }
177
+
178
+    public void setCattleNo(String cattleNo)
179
+    {
180
+        this.cattleNo = cattleNo;
181
+    }
182
+
183
+    public String getEarTagNumber()
184
+    {
185
+        return earTagNumber;
186
+    }
187
+
188
+    public void setEarTagNumber(String earTagNumber)
189
+    {
190
+        this.earTagNumber = earTagNumber;
191
+    }
192
+
193
+    public String getCattleVariety()
194
+    {
195
+        return cattleVariety;
196
+    }
197
+
198
+    public void setCattleVariety(String cattleVariety)
199
+    {
200
+        this.cattleVariety = cattleVariety;
201
+    }
202
+
203
+    public String getCattleSex()
204
+    {
205
+        return cattleSex;
206
+    }
207
+
208
+    public void setCattleSex(String cattleSex)
209
+    {
210
+        this.cattleSex = cattleSex;
211
+    }
212
+
213
+    public String getCattleSource()
214
+    {
215
+        return cattleSource;
216
+    }
217
+
218
+    public void setCattleSource(String cattleSource)
219
+    {
220
+        this.cattleSource = cattleSource;
221
+    }
222
+
223
+    public String getFatherNumber()
224
+    {
225
+        return fatherNumber;
226
+    }
227
+
228
+    public void setFatherNumber(String fatherNumber)
229
+    {
230
+        this.fatherNumber = fatherNumber;
231
+    }
232
+
233
+    public String getMotherNumber()
234
+    {
235
+        return motherNumber;
236
+    }
237
+
238
+    public void setMotherNumber(String motherNumber)
239
+    {
240
+        this.motherNumber = motherNumber;
241
+    }
242
+
243
+    public Double getWeight()
244
+    {
245
+        return weight;
246
+    }
247
+
248
+    public void setWeight(Double weight)
249
+    {
250
+        this.weight = weight;
251
+    }
252
+
253
+    public String getCattlePicture()
254
+    {
255
+        return cattlePicture;
256
+    }
257
+
258
+    public void setCattlePicture(String cattlePicture)
259
+    {
260
+        this.cattlePicture = cattlePicture;
261
+    }
262
+
263
+    public String getRegistrant()
264
+    {
265
+        return registrant;
266
+    }
267
+
268
+    public void setRegistrant(String registrant)
269
+    {
270
+        this.registrant = registrant;
271
+    }
272
+
273
+    public String getEntryTime()
274
+    {
275
+        return entryTime;
276
+    }
277
+
278
+    public void setEntryTime(String entryTime)
279
+    {
280
+        this.entryTime = entryTime;
281
+    }
282
+
283
+    public String getBirthDate()
284
+    {
285
+        return birthDate;
286
+    }
287
+
288
+    public void setBirthDate(String birthDate)
289
+    {
290
+        this.birthDate = birthDate;
291
+    }
292
+
293
+    public String getCreateTime()
294
+    {
295
+        return createTime;
296
+    }
297
+
298
+    public void setCreateTime(String createTime)
299
+    {
300
+        this.createTime = createTime;
301
+    }
302
+
303
+    public String getRemark()
304
+    {
305
+        return remark;
306
+    }
307
+
308
+    public void setRemark(String remark)
309
+    {
310
+        this.remark = remark;
311
+    }
312
+}

+ 7 - 7
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/domain/dto/YakAssetBundleDto.java

@@ -1,6 +1,5 @@
1 1
 package com.ruoyi.web.modules.industryservice.domain.dto;
2 2
 
3
-import java.util.ArrayList;
4 3
 import java.util.List;
5 4
 import com.ruoyi.web.modules.industryservice.domain.BizYakAsset;
6 5
 import com.ruoyi.web.modules.industryservice.domain.BizYakBatchRel;
@@ -19,17 +18,18 @@ public class YakAssetBundleDto
19 18
 
20 19
     private String thirdPartyPastureCode;
21 20
 
22
-    private List<BizYakPhysioSeries> physioSeries = new ArrayList<>();
21
+    /** null 表示本次同步不覆盖该子表 */
22
+    private List<BizYakPhysioSeries> physioSeries;
23 23
 
24
-    private List<BizYakGrowthRecord> growthRecords = new ArrayList<>();
24
+    private List<BizYakGrowthRecord> growthRecords;
25 25
 
26
-    private List<BizYakReproductionRecord> reproductionRecords = new ArrayList<>();
26
+    private List<BizYakReproductionRecord> reproductionRecords;
27 27
 
28
-    private List<BizYakFeedingRecord> feedingRecords = new ArrayList<>();
28
+    private List<BizYakFeedingRecord> feedingRecords;
29 29
 
30
-    private List<BizYakPenRel> penRecords = new ArrayList<>();
30
+    private List<BizYakPenRel> penRecords;
31 31
 
32
-    private List<BizYakBatchRel> batchRecords = new ArrayList<>();
32
+    private List<BizYakBatchRel> batchRecords;
33 33
 
34 34
     public BizYakAsset getAsset()
35 35
     {

+ 2 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/mapper/BizYakAssetMapper.java

@@ -9,6 +9,8 @@ public interface BizYakAssetMapper
9 9
 
10 10
     BizYakAsset selectBizYakAssetByYakNo(String yakNo);
11 11
 
12
+    BizYakAsset selectBizYakAssetByExternalId(Long externalId);
13
+
12 14
     List<BizYakAsset> selectBizYakAssetList(BizYakAsset query);
13 15
 
14 16
     int insertBizYakAsset(BizYakAsset row);

+ 53 - 10
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/service/YakAssetBundleSyncTxService.java

@@ -77,7 +77,7 @@ public class YakAssetBundleSyncTxService
77 77
             incoming.setAssetStatus(YakAssetStatusMapper.normalizeLegacyNumericStatus(incoming.getAssetStatus()));
78 78
             YakAssetValidation.validateAssetStatus(incoming.getAssetStatus());
79 79
         }
80
-        resolvePasture(incoming);
80
+        resolvePasture(incoming, bundle);
81 81
         if (incoming.getBirthDate() != null)
82 82
         {
83 83
             incoming.setAgeMonths(YakAssetValidation.resolveAgeMonths(incoming.getBirthDate(), syncTime));
@@ -85,7 +85,7 @@ public class YakAssetBundleSyncTxService
85 85
         incoming.setLastSyncTime(syncTime);
86 86
         incoming.setDelFlag(YakAssetRules.DEL_FLAG_NORMAL);
87 87
 
88
-        BizYakAsset existing = bizYakAssetMapper.selectBizYakAssetByYakNo(yakNo);
88
+        BizYakAsset existing = resolveExisting(incoming);
89 89
         Long yakAssetId;
90 90
         boolean inserted;
91 91
         if (existing == null)
@@ -105,10 +105,35 @@ public class YakAssetBundleSyncTxService
105 105
         return inserted;
106 106
     }
107 107
 
108
-    private void resolvePasture(BizYakAsset incoming)
108
+    private BizYakAsset resolveExisting(BizYakAsset incoming)
109
+    {
110
+        if (incoming.getExternalId() != null)
111
+        {
112
+            BizYakAsset byExternal = bizYakAssetMapper.selectBizYakAssetByExternalId(incoming.getExternalId());
113
+            if (byExternal != null)
114
+            {
115
+                return byExternal;
116
+            }
117
+        }
118
+        return bizYakAssetMapper.selectBizYakAssetByYakNo(incoming.getYakNo());
119
+    }
120
+
121
+    private void resolvePasture(BizYakAsset incoming, YakAssetBundleDto bundle)
109 122
     {
110 123
         BizPasture pasture = null;
111
-        if (StringUtils.isNotEmpty(incoming.getPastureName()))
124
+        if (bundle != null && StringUtils.isNotEmpty(bundle.getThirdPartyPastureCode()))
125
+        {
126
+            try
127
+            {
128
+                Long farmId = Long.parseLong(bundle.getThirdPartyPastureCode().trim());
129
+                pasture = bizPastureMapper.selectBizPastureByExternalId(farmId);
130
+            }
131
+            catch (NumberFormatException ignored)
132
+            {
133
+                // 非数字编码时回退名称匹配
134
+            }
135
+        }
136
+        if (pasture == null && StringUtils.isNotEmpty(incoming.getPastureName()))
112 137
         {
113 138
             pasture = bizPastureMapper.selectBizPastureByExactName(incoming.getPastureName().trim());
114 139
         }
@@ -125,12 +150,30 @@ public class YakAssetBundleSyncTxService
125 150
 
126 151
     private void replaceChildRows(Long yakAssetId, YakAssetBundleDto bundle)
127 152
     {
128
-        physioSeriesMapper.deleteByYakAssetId(yakAssetId);
129
-        growthRecordMapper.deleteByYakAssetId(yakAssetId);
130
-        reproductionRecordMapper.deleteByYakAssetId(yakAssetId);
131
-        feedingRecordMapper.deleteByYakAssetId(yakAssetId);
132
-        penRelMapper.deleteByYakAssetId(yakAssetId);
133
-        batchRelMapper.deleteByYakAssetId(yakAssetId);
153
+        if (bundle.getPhysioSeries() != null)
154
+        {
155
+            physioSeriesMapper.deleteByYakAssetId(yakAssetId);
156
+        }
157
+        if (bundle.getGrowthRecords() != null)
158
+        {
159
+            growthRecordMapper.deleteByYakAssetId(yakAssetId);
160
+        }
161
+        if (bundle.getReproductionRecords() != null)
162
+        {
163
+            reproductionRecordMapper.deleteByYakAssetId(yakAssetId);
164
+        }
165
+        if (bundle.getFeedingRecords() != null)
166
+        {
167
+            feedingRecordMapper.deleteByYakAssetId(yakAssetId);
168
+        }
169
+        if (bundle.getPenRecords() != null || StringUtils.isNotEmpty(bundle.getAsset().getPenLocation()))
170
+        {
171
+            penRelMapper.deleteByYakAssetId(yakAssetId);
172
+        }
173
+        if (bundle.getBatchRecords() != null || StringUtils.isNotEmpty(bundle.getAsset().getBatchNo()))
174
+        {
175
+            batchRelMapper.deleteByYakAssetId(yakAssetId);
176
+        }
134 177
 
135 178
         if (!CollectionUtils.isEmpty(bundle.getPhysioSeries()))
136 179
         {

+ 80 - 31
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/support/ThirdPartyYakClientImpl.java

@@ -2,6 +2,7 @@ package com.ruoyi.web.modules.industryservice.support;
2 2
 
3 3
 import java.io.InputStream;
4 4
 import java.nio.charset.StandardCharsets;
5
+import java.util.ArrayList;
5 6
 import java.util.Collections;
6 7
 import java.util.List;
7 8
 import org.springframework.beans.factory.annotation.Autowired;
@@ -11,23 +12,29 @@ import org.springframework.http.HttpHeaders;
11 12
 import org.springframework.http.HttpMethod;
12 13
 import org.springframework.http.ResponseEntity;
13 14
 import org.springframework.stereotype.Component;
15
+import org.springframework.util.CollectionUtils;
14 16
 import org.springframework.util.StreamUtils;
17
+import org.springframework.util.StringUtils;
15 18
 import org.springframework.web.client.RestClientException;
16 19
 import org.springframework.web.client.RestTemplate;
20
+import org.springframework.web.util.UriComponentsBuilder;
17 21
 import com.fasterxml.jackson.core.type.TypeReference;
18 22
 import com.fasterxml.jackson.databind.ObjectMapper;
19 23
 import com.ruoyi.common.exception.ServiceException;
24
+import com.ruoyi.web.modules.industryservice.domain.dto.OpenApiPageResult;
25
+import com.ruoyi.web.modules.industryservice.domain.dto.OpenApiResponse;
26
+import com.ruoyi.web.modules.industryservice.domain.dto.OpenYakEntryDto;
20 27
 import com.ruoyi.web.modules.industryservice.domain.dto.YakAssetBundleDto;
21 28
 
22 29
 /**
23
- * 第三方牦牛资产客户端:stub 读取 classpath JSON;http 调用同源开放接口
30
+ * 第三方牦牛资产客户端:stub 读取 OpenAPI 样例;http 分页调用入栏建档列表
24 31
  */
25 32
 @Component
26 33
 public class ThirdPartyYakClientImpl implements ThirdPartyYakClient
27 34
 {
28
-    private static final String STUB_RESOURCE = "thirdparty/stub-yak-bundles.json";
35
+    private static final String STUB_RESOURCE = "thirdparty/stub-yak-entries.json";
29 36
 
30
-    /** 入栏建档列表(与牧场接口同平台,路径以 Apifox 文档为准) */
37
+    /** 查询入栏建档列表(Apifox 文档) */
31 38
     private static final String LIST_PATH = "/open-api/v1/farming/entry-filings";
32 39
 
33 40
     private final ObjectMapper objectMapper = ThirdPartyFarmingHttpSupport.createObjectMapper();
@@ -42,14 +49,19 @@ public class ThirdPartyYakClientImpl implements ThirdPartyYakClient
42 49
         {
43 50
             throw new ServiceException("第三方牦牛数据同步未启用");
44 51
         }
52
+        List<OpenYakEntryDto> entries;
45 53
         if ("http".equalsIgnoreCase(properties.getMode()))
46 54
         {
47
-            return fetchFromHttp();
55
+            entries = fetchFromHttp();
48 56
         }
49
-        return fetchFromStub();
57
+        else
58
+        {
59
+            entries = fetchFromStub();
60
+        }
61
+        return YakEntryOpenApiMapper.toBundles(entries);
50 62
     }
51 63
 
52
-    private List<YakAssetBundleDto> fetchFromStub()
64
+    private List<OpenYakEntryDto> fetchFromStub()
53 65
     {
54 66
         try
55 67
         {
@@ -61,49 +73,86 @@ public class ThirdPartyYakClientImpl implements ThirdPartyYakClient
61 73
             try (InputStream in = resource.getInputStream())
62 74
             {
63 75
                 String json = StreamUtils.copyToString(in, StandardCharsets.UTF_8);
64
-                return objectMapper.readValue(json, new TypeReference<List<YakAssetBundleDto>>()
76
+                return objectMapper.readValue(json, new TypeReference<List<OpenYakEntryDto>>()
65 77
                 {
66 78
                 });
67 79
             }
68 80
         }
81
+        catch (ServiceException e)
82
+        {
83
+            throw e;
84
+        }
69 85
         catch (Exception e)
70 86
         {
71 87
             throw new ServiceException("读取第三方样例数据失败:" + e.getMessage());
72 88
         }
73 89
     }
74 90
 
75
-    private List<YakAssetBundleDto> fetchFromHttp()
91
+    private List<OpenYakEntryDto> fetchFromHttp()
76 92
     {
77 93
         ThirdPartyFarmingHttpSupport.requireBaseUrl(properties, "第三方生产管理系统 base-url 未配置");
78 94
         RestTemplate restTemplate = ThirdPartyFarmingHttpSupport.createRestTemplate(properties);
79 95
         HttpHeaders headers = ThirdPartyFarmingHttpSupport.buildOpenApiHeaders(properties);
80
-        String url = ThirdPartyFarmingHttpSupport.resolveUrl(properties, LIST_PATH);
81
-        try
96
+        List<OpenYakEntryDto> all = new ArrayList<>();
97
+        int pageNum = 1;
98
+        int pageSize = Math.min(Math.max(properties.getPageSize(), 1), 200);
99
+        Long total = null;
100
+        while (true)
82 101
         {
83
-            ResponseEntity<String> response = restTemplate.exchange(
84
-                    url,
85
-                    HttpMethod.GET,
86
-                    new HttpEntity<>(headers),
87
-                    String.class);
88
-            if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null)
102
+            String url = UriComponentsBuilder.fromHttpUrl(ThirdPartyFarmingHttpSupport.resolveUrl(properties, LIST_PATH))
103
+                    .queryParam("pageNum", pageNum)
104
+                    .queryParam("pageSize", pageSize)
105
+                    .toUriString();
106
+            try
89 107
             {
90
-                throw new ServiceException("第三方接口返回异常");
108
+                ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET,
109
+                        new HttpEntity<>(headers), String.class);
110
+                if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null)
111
+                {
112
+                    throw new ServiceException("第三方接口返回异常");
113
+                }
114
+                OpenApiResponse<OpenApiPageResult<OpenYakEntryDto>> body = objectMapper.readValue(response.getBody(),
115
+                        new TypeReference<OpenApiResponse<OpenApiPageResult<OpenYakEntryDto>>>()
116
+                        {
117
+                        });
118
+                if (body == null || body.getCode() == null || body.getCode() != 0)
119
+                {
120
+                    String msg = body != null && StringUtils.hasText(body.getMessage()) ? body.getMessage() : "业务失败";
121
+                    throw new ServiceException("第三方接口:" + msg);
122
+                }
123
+                OpenApiPageResult<OpenYakEntryDto> page = body.getData();
124
+                if (page == null || CollectionUtils.isEmpty(page.getRecords()))
125
+                {
126
+                    break;
127
+                }
128
+                all.addAll(page.getRecords());
129
+                if (total == null && page.getTotal() != null)
130
+                {
131
+                    total = page.getTotal();
132
+                }
133
+                if (page.getRecords().size() < pageSize)
134
+                {
135
+                    break;
136
+                }
137
+                if (total != null && all.size() >= total)
138
+                {
139
+                    break;
140
+                }
141
+                pageNum++;
91 142
             }
92
-            return objectMapper.readValue(response.getBody(), new TypeReference<List<YakAssetBundleDto>>()
143
+            catch (ServiceException e)
93 144
             {
94
-            });
95
-        }
96
-        catch (RestClientException e)
97
-        {
98
-            throw new ServiceException("第三方服务不可用:" + e.getMessage());
99
-        }
100
-        catch (ServiceException e)
101
-        {
102
-            throw e;
103
-        }
104
-        catch (Exception e)
105
-        {
106
-            throw new ServiceException("解析第三方数据失败:" + e.getMessage());
145
+                throw e;
146
+            }
147
+            catch (RestClientException e)
148
+            {
149
+                throw new ServiceException("第三方服务不可用:" + e.getMessage());
150
+            }
151
+            catch (Exception e)
152
+            {
153
+                throw new ServiceException("解析第三方数据失败:" + e.getMessage());
154
+            }
107 155
         }
156
+        return all;
108 157
     }
109 158
 }

+ 214 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/industryservice/support/YakEntryOpenApiMapper.java

@@ -0,0 +1,214 @@
1
+package com.ruoyi.web.modules.industryservice.support;
2
+
3
+import java.math.BigDecimal;
4
+import java.text.ParseException;
5
+import java.text.SimpleDateFormat;
6
+import java.util.ArrayList;
7
+import java.util.Collections;
8
+import java.util.Date;
9
+import java.util.List;
10
+import java.util.TimeZone;
11
+import com.ruoyi.common.exception.ServiceException;
12
+import com.ruoyi.common.utils.StringUtils;
13
+import com.ruoyi.web.modules.industryservice.domain.BizYakAsset;
14
+import com.ruoyi.web.modules.industryservice.domain.BizYakBatchRel;
15
+import com.ruoyi.web.modules.industryservice.domain.BizYakPenRel;
16
+import com.ruoyi.web.modules.industryservice.domain.dto.OpenYakEntryDto;
17
+import com.ruoyi.web.modules.industryservice.domain.dto.YakAssetBundleDto;
18
+
19
+/**
20
+ * 将第三方入栏建档 OpenAPI 记录映射为本地同步数据包。
21
+ */
22
+public final class YakEntryOpenApiMapper
23
+{
24
+    private static final SimpleDateFormat DATE_TIME = createDateTimeFormat();
25
+
26
+    private static final SimpleDateFormat DATE_ONLY = createDateOnlyFormat();
27
+
28
+    private YakEntryOpenApiMapper()
29
+    {
30
+    }
31
+
32
+    public static YakAssetBundleDto toBundle(OpenYakEntryDto dto)
33
+    {
34
+        if (dto == null)
35
+        {
36
+            throw new ServiceException("同步数据无效");
37
+        }
38
+        String yakNo = resolveYakNo(dto);
39
+        if (StringUtils.isEmpty(yakNo))
40
+        {
41
+            throw new ServiceException("牦牛编号不能为空");
42
+        }
43
+        Long externalId = dto.getCattleId() != null ? dto.getCattleId() : dto.getId();
44
+        if (externalId == null)
45
+        {
46
+            throw new ServiceException("第三方牛只 ID 不能为空");
47
+        }
48
+
49
+        BizYakAsset asset = new BizYakAsset();
50
+        asset.setExternalId(externalId);
51
+        asset.setYakNo(yakNo);
52
+        asset.setPastureName(trimToNull(dto.getFarmName()));
53
+        asset.setBatchNo(resolveBatchNo(dto));
54
+        asset.setGender(normalizeGender(dto.getCattleSex()));
55
+        asset.setBirthDate(parseDate(dto.getBirthDate()));
56
+        asset.setEntryDate(parseDate(dto.getEntryTime()));
57
+        asset.setEntryWeightKg(toBigDecimal(dto.getWeight()));
58
+        asset.setSource(trimToNull(dto.getCattleSource()));
59
+        asset.setPenLocation(trimToNull(dto.getFarmEnclosureName()));
60
+        asset.setLocation(trimToNull(dto.getFarmLocation()));
61
+        asset.setFatherYakNo(trimToNull(dto.getFatherNumber()));
62
+        asset.setMotherYakNo(trimToNull(dto.getMotherNumber()));
63
+        asset.setStatusChangeReason(trimToNull(dto.getRemark()));
64
+        asset.setAssetStatus(YakAssetRules.ASSET_STATUS_MIN);
65
+
66
+        YakAssetBundleDto bundle = new YakAssetBundleDto();
67
+        bundle.setAsset(asset);
68
+        if (dto.getFarmId() != null)
69
+        {
70
+            bundle.setThirdPartyPastureCode(String.valueOf(dto.getFarmId()));
71
+        }
72
+        bundle.setPenRecords(buildPenRecords(dto));
73
+        bundle.setBatchRecords(buildBatchRecords(dto, asset.getBatchNo()));
74
+        return bundle;
75
+    }
76
+
77
+    private static String resolveYakNo(OpenYakEntryDto dto)
78
+    {
79
+        if (StringUtils.isNotEmpty(dto.getEarTagNumber()))
80
+        {
81
+            return dto.getEarTagNumber().trim();
82
+        }
83
+        if (StringUtils.isNotEmpty(dto.getCattleNo()))
84
+        {
85
+            return dto.getCattleNo().trim();
86
+        }
87
+        return null;
88
+    }
89
+
90
+    private static String resolveBatchNo(OpenYakEntryDto dto)
91
+    {
92
+        if (StringUtils.isNotEmpty(dto.getEntryCycle()))
93
+        {
94
+            return dto.getEntryCycle().trim();
95
+        }
96
+        if (dto.getBatchId() != null)
97
+        {
98
+            return String.valueOf(dto.getBatchId());
99
+        }
100
+        return null;
101
+    }
102
+
103
+    private static String normalizeGender(String cattleSex)
104
+    {
105
+        if (StringUtils.isEmpty(cattleSex))
106
+        {
107
+            return null;
108
+        }
109
+        String sex = cattleSex.trim();
110
+        if ("1".equals(sex) || "M".equalsIgnoreCase(sex) || "公".equals(sex))
111
+        {
112
+            return "公";
113
+        }
114
+        if ("2".equals(sex) || "F".equalsIgnoreCase(sex) || "母".equals(sex))
115
+        {
116
+            return "母";
117
+        }
118
+        return sex;
119
+    }
120
+
121
+    private static List<BizYakPenRel> buildPenRecords(OpenYakEntryDto dto)
122
+    {
123
+        if (StringUtils.isEmpty(dto.getFarmEnclosureName()))
124
+        {
125
+            return null;
126
+        }
127
+        BizYakPenRel pen = new BizYakPenRel();
128
+        pen.setPenName(dto.getFarmEnclosureName().trim());
129
+        return Collections.singletonList(pen);
130
+    }
131
+
132
+    private static List<BizYakBatchRel> buildBatchRecords(OpenYakEntryDto dto, String batchNo)
133
+    {
134
+        String resolved = batchNo;
135
+        if (StringUtils.isEmpty(resolved))
136
+        {
137
+            return null;
138
+        }
139
+        BizYakBatchRel batch = new BizYakBatchRel();
140
+        batch.setBatchNo(resolved);
141
+        return Collections.singletonList(batch);
142
+    }
143
+
144
+    public static List<YakAssetBundleDto> toBundles(List<OpenYakEntryDto> entries)
145
+    {
146
+        if (entries == null || entries.isEmpty())
147
+        {
148
+            return Collections.emptyList();
149
+        }
150
+        List<YakAssetBundleDto> bundles = new ArrayList<>(entries.size());
151
+        for (OpenYakEntryDto entry : entries)
152
+        {
153
+            bundles.add(toBundle(entry));
154
+        }
155
+        return bundles;
156
+    }
157
+
158
+    private static BigDecimal toBigDecimal(Double value)
159
+    {
160
+        return value == null ? null : BigDecimal.valueOf(value);
161
+    }
162
+
163
+    private static String trimToNull(String value)
164
+    {
165
+        if (StringUtils.isEmpty(value))
166
+        {
167
+            return null;
168
+        }
169
+        return value.trim();
170
+    }
171
+
172
+    private static Date parseDate(String value)
173
+    {
174
+        if (StringUtils.isEmpty(value))
175
+        {
176
+            return null;
177
+        }
178
+        String text = value.trim();
179
+        try
180
+        {
181
+            synchronized (DATE_TIME)
182
+            {
183
+                if (text.length() > 10)
184
+                {
185
+                    return DATE_TIME.parse(text);
186
+                }
187
+            }
188
+            synchronized (DATE_ONLY)
189
+            {
190
+                return DATE_ONLY.parse(text);
191
+            }
192
+        }
193
+        catch (ParseException e)
194
+        {
195
+            return null;
196
+        }
197
+    }
198
+
199
+    private static SimpleDateFormat createDateTimeFormat()
200
+    {
201
+        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
202
+        df.setTimeZone(TimeZone.getTimeZone("GMT+8"));
203
+        df.setLenient(false);
204
+        return df;
205
+    }
206
+
207
+    private static SimpleDateFormat createDateOnlyFormat()
208
+    {
209
+        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd");
210
+        df.setTimeZone(TimeZone.getTimeZone("GMT+8"));
211
+        df.setLenient(false);
212
+        return df;
213
+    }
214
+}

+ 11 - 3
baqing-admin/src/main/resources/mapper/industryservice/BizYakAssetMapper.xml

@@ -4,6 +4,7 @@
4 4
 
5 5
     <resultMap type="com.ruoyi.web.modules.industryservice.domain.BizYakAsset" id="BizYakAssetResult">
6 6
         <id     property="id"                 column="id"/>
7
+        <result property="externalId"         column="external_id"/>
7 8
         <result property="yakNo"              column="yak_no"/>
8 9
         <result property="pastureId"          column="pasture_id"/>
9 10
         <result property="pastureName"        column="pasture_name"/>
@@ -35,7 +36,7 @@
35 36
     </resultMap>
36 37
 
37 38
     <sql id="selectVo">
38
-        select id, yak_no, pasture_id, pasture_name, batch_no, gender, birth_date, age_months,
39
+        select id, external_id, yak_no, pasture_id, pasture_name, batch_no, gender, birth_date, age_months,
39 40
                entry_date, entry_weight_kg, source, breeding_method, asset_status, status_change_date,
40 41
                status_change_reason, pen_location, expected_out_date, supplement_plan,
41 42
                realtime_temp, realtime_steps, env_temp, location, physio_collect_time,
@@ -53,6 +54,11 @@
53 54
         where yak_no = #{yakNo} and del_flag = '0'
54 55
     </select>
55 56
 
57
+    <select id="selectBizYakAssetByExternalId" parameterType="long" resultMap="BizYakAssetResult">
58
+        <include refid="selectVo"/>
59
+        where external_id = #{externalId} and del_flag = '0'
60
+    </select>
61
+
56 62
     <select id="selectBizYakAssetList" parameterType="com.ruoyi.web.modules.industryservice.domain.BizYakAsset" resultMap="BizYakAssetResult">
57 63
         <include refid="selectVo"/>
58 64
         <where>
@@ -69,13 +75,13 @@
69 75
 
70 76
     <insert id="insertBizYakAsset" parameterType="com.ruoyi.web.modules.industryservice.domain.BizYakAsset" useGeneratedKeys="true" keyProperty="id">
71 77
         insert into biz_yak_asset (
72
-            yak_no, pasture_id, pasture_name, batch_no, gender, birth_date, age_months,
78
+            external_id, yak_no, pasture_id, pasture_name, batch_no, gender, birth_date, age_months,
73 79
             entry_date, entry_weight_kg, source, breeding_method, asset_status, status_change_date,
74 80
             status_change_reason, pen_location, expected_out_date, supplement_plan,
75 81
             realtime_temp, realtime_steps, env_temp, location, physio_collect_time,
76 82
             father_yak_no, mother_yak_no, last_sync_time, del_flag, create_time
77 83
         ) values (
78
-            #{yakNo}, #{pastureId}, #{pastureName}, #{batchNo}, #{gender}, #{birthDate}, #{ageMonths},
84
+            #{externalId}, #{yakNo}, #{pastureId}, #{pastureName}, #{batchNo}, #{gender}, #{birthDate}, #{ageMonths},
79 85
             #{entryDate}, #{entryWeightKg}, #{source}, #{breedingMethod}, #{assetStatus}, #{statusChangeDate},
80 86
             #{statusChangeReason}, #{penLocation}, #{expectedOutDate}, #{supplementPlan},
81 87
             #{realtimeTemp}, #{realtimeSteps}, #{envTemp}, #{location}, #{physioCollectTime},
@@ -85,6 +91,8 @@
85 91
 
86 92
     <update id="updateBizYakAsset" parameterType="com.ruoyi.web.modules.industryservice.domain.BizYakAsset">
87 93
         update biz_yak_asset set
94
+            external_id = #{externalId},
95
+            yak_no = #{yakNo},
88 96
             pasture_id = #{pastureId},
89 97
             pasture_name = #{pastureName},
90 98
             batch_no = #{batchNo},

+ 0 - 78
baqing-admin/src/main/resources/thirdparty/stub-yak-bundles.json

@@ -1,78 +0,0 @@
1
-[
2
-  {
3
-    "thirdPartyPastureCode": "P001",
4
-    "asset": {
5
-      "yakNo": "YAK20250001",
6
-      "pastureName": "示范牧场",
7
-      "batchNo": "B2025-01",
8
-      "gender": "母",
9
-      "birthDate": "2023-06-01",
10
-      "entryDate": "2023-08-01",
11
-      "entryWeightKg": 85.5,
12
-      "source": "自繁",
13
-      "breedingMethod": "半舍饲",
14
-      "assetStatus": 1,
15
-      "statusChangeDate": "2025-05-01",
16
-      "statusChangeReason": "入栏饲养",
17
-      "penLocation": "1号圈舍",
18
-      "expectedOutDate": "2026-10-01",
19
-      "supplementPlan": "冬季补饲青贮+精料",
20
-      "realtimeTemp": 38.6,
21
-      "realtimeSteps": 3200,
22
-      "envTemp": 12.5,
23
-      "location": "东经91.0,北纬32.0",
24
-      "fatherYakNo": "YAK-F001",
25
-      "motherYakNo": "YAK-M001"
26
-    },
27
-    "physioSeries": [
28
-      {
29
-        "collectTime": "2025-05-18 08:00:00",
30
-        "bodyTemp": 38.5,
31
-        "steps": 3100,
32
-        "envTemp": 11.0
33
-      }
34
-    ],
35
-    "growthRecords": [
36
-      {
37
-        "dayAge": 365,
38
-        "weightKg": 220.0,
39
-        "heightCm": 120.0,
40
-        "chestCm": 150.0,
41
-        "lengthCm": 130.0,
42
-        "collectTime": "2025-05-01 10:00:00",
43
-        "dataKind": 1
44
-      },
45
-      {
46
-        "dayAge": 400,
47
-        "weightKg": 235.0,
48
-        "collectTime": "2025-06-01 10:00:00",
49
-        "dataKind": 2
50
-      }
51
-    ],
52
-    "reproductionRecords": [
53
-      {
54
-        "calvingDate": "2024-08-15",
55
-        "dayAge": 300,
56
-        "parity": 1,
57
-        "calvingIntervalDays": 0,
58
-        "calfCount": 1,
59
-        "liveCalfCount": 1,
60
-        "calfSurvivalRate": 1.0,
61
-        "calfBirthWeightKg": 25.0,
62
-        "dystociaFlag": "0"
63
-      }
64
-    ],
65
-    "feedingRecords": [
66
-      {
67
-        "feedTarget": "个体",
68
-        "startDayAge": 0,
69
-        "endDayAge": 90,
70
-        "feedType": "青贮",
71
-        "totalSupplementKg": 45.0,
72
-        "dailySupplementKg": 0.5
73
-      }
74
-    ],
75
-    "penRecords": [{ "penName": "1号圈舍" }],
76
-    "batchRecords": [{ "batchNo": "B2025-01" }]
77
-  }
78
-]

+ 25 - 0
baqing-admin/src/main/resources/thirdparty/stub-yak-entries.json

@@ -0,0 +1,25 @@
1
+[
2
+  {
3
+    "id": 1001,
4
+    "farmId": 1,
5
+    "farmName": "示范牧场",
6
+    "farmEnclosureId": 11,
7
+    "farmEnclosureName": "1号圈舍",
8
+    "batchId": 202501,
9
+    "entryCycle": "B2025-01",
10
+    "farmLocation": "东经91.0,北纬32.0",
11
+    "cattleId": 90001,
12
+    "cattleNo": "YAK20250001",
13
+    "earTagNumber": "YAK20250001",
14
+    "cattleVariety": "牦牛",
15
+    "cattleSex": "母",
16
+    "cattleSource": "自繁",
17
+    "fatherNumber": "YAK-F001",
18
+    "motherNumber": "YAK-M001",
19
+    "weight": 220.0,
20
+    "entryTime": "2023-08-01 10:00:00",
21
+    "birthDate": "2023-06-01",
22
+    "createTime": "2023-08-01 10:00:00",
23
+    "remark": "入栏建档样例"
24
+  }
25
+]

+ 57 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/industryservice/support/YakEntryOpenApiMapperTest.java

@@ -0,0 +1,57 @@
1
+package com.ruoyi.web.modules.industryservice.support;
2
+
3
+import static org.junit.jupiter.api.Assertions.assertEquals;
4
+import static org.junit.jupiter.api.Assertions.assertNotNull;
5
+
6
+import java.math.BigDecimal;
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+import com.ruoyi.web.modules.industryservice.domain.BizYakAsset;
10
+import com.ruoyi.web.modules.industryservice.domain.dto.OpenYakEntryDto;
11
+import com.ruoyi.web.modules.industryservice.domain.dto.YakAssetBundleDto;
12
+
13
+@DisplayName("YakEntryOpenApiMapper")
14
+class YakEntryOpenApiMapperTest
15
+{
16
+    @Test
17
+    @DisplayName("OpenAPI 字段映射到 biz_yak_asset")
18
+    void mapsOpenApiFields()
19
+    {
20
+        OpenYakEntryDto dto = new OpenYakEntryDto();
21
+        dto.setCattleId(90001L);
22
+        dto.setEarTagNumber("ET-001");
23
+        dto.setCattleNo("CN-001");
24
+        dto.setFarmId(1L);
25
+        dto.setFarmName("示范牧场");
26
+        dto.setFarmEnclosureName("1号圈舍");
27
+        dto.setEntryCycle("B2025-01");
28
+        dto.setFarmLocation("东经91.0,北纬32.0");
29
+        dto.setCattleSex("母");
30
+        dto.setCattleSource("自繁");
31
+        dto.setFatherNumber("F-01");
32
+        dto.setMotherNumber("M-01");
33
+        dto.setWeight(220.5);
34
+        dto.setEntryTime("2023-08-01 10:00:00");
35
+        dto.setBirthDate("2023-06-01");
36
+        dto.setRemark("测试备注");
37
+
38
+        YakAssetBundleDto bundle = YakEntryOpenApiMapper.toBundle(dto);
39
+        BizYakAsset asset = bundle.getAsset();
40
+        assertNotNull(asset);
41
+        assertEquals(90001L, asset.getExternalId());
42
+        assertEquals("ET-001", asset.getYakNo());
43
+        assertEquals("示范牧场", asset.getPastureName());
44
+        assertEquals("B2025-01", asset.getBatchNo());
45
+        assertEquals("母", asset.getGender());
46
+        assertEquals("自繁", asset.getSource());
47
+        assertEquals("1号圈舍", asset.getPenLocation());
48
+        assertEquals("东经91.0,北纬32.0", asset.getLocation());
49
+        assertEquals("F-01", asset.getFatherYakNo());
50
+        assertEquals("M-01", asset.getMotherYakNo());
51
+        assertEquals(new BigDecimal("220.5"), asset.getEntryWeightKg());
52
+        assertEquals("测试备注", asset.getStatusChangeReason());
53
+        assertEquals("1", bundle.getThirdPartyPastureCode());
54
+        assertEquals(1, bundle.getPenRecords().size());
55
+        assertEquals(1, bundle.getBatchRecords().size());
56
+    }
57
+}

+ 7 - 4
doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理功能需求.md

@@ -133,12 +133,14 @@ flowchart TD
133 133
 | --- | --- |
134 134
 | 触发 | 用户点击「同步」;定时任务是否启用另定 |
135 135
 | 权限 | 仅同步权限角色可执行 |
136
-| 合并键 | 以**牦牛编号**为准:有则更新,无则新增 |
137
-| 范围 | 默认拉取授权范围内档案;是否按牧场勾选同步,评审确认 |
136
+| 数据源 | 第三方生产管理系统开放接口 `GET /open-api/v1/farming/entry-filings`(入栏建档列表,字段见草稿 `20260611111807_455_398.jpg`) |
137
+| 鉴权 | 与牧场管理同源:`third-party.farming` 配置 `base-url`、`app-key`、`app-secret` |
138
+| 合并键 | 优先以第三方 **cattleId**(`external_id`)upsert;辅以 **耳标编号 earTagNumber**(无则 cattleNo)作为 `yak_no` |
139
+| 范围 | 分页拉取(`pageNum`/`pageSize`≤200)直至取完 |
138 140
 | 反馈 | 结束提示成功/失败及摘要(新增/更新/失败条数) |
139
-| 牧场映射 | 「所属牧场」与牧场主数据或第三方编码映射;无法映射时展示第三方名称或「未关联」 |
141
+| 牧场映射 | `farmId` 关联 `biz_pasture.external_id`;失败则冗余 `farmName`,展示「未关联」 |
140 142
 | 失败 | 第三方超时、鉴权失败等须明确提示,**不得**误删或清空已有档案 |
141
-| 子数据 | 同步宜覆盖详情八块相关数据(基础、系谱、生理时序、生长、繁殖、饲喂、圈舍、批次) |
143
+| 子数据 | **本期列表接口**同步主表 + 圈舍/批次关联;生理/生长/繁殖/饲喂等子表待详情接口对接后增量覆盖(未返回的子表不清空) |
142 144
 
143 145
 同步成功后,列表与详情展示最近一次成功结果;列表宜可展示「数据截至:最近同步时间」类提示(**§8**)。
144 146
 
@@ -324,6 +326,7 @@ flowchart TD
324 326
 | 生长「切换+预测」 | §5.5(4)曲线模式单独说明 |
325 327
 | 生理与曲线图关系 | §5.5(3)区分当前值与近一月曲线 |
326 328
 | 列表缺分页、重置 | §5.1、§5.2 |
329
+| 入栏建档 OpenAPI 对接 | §5.3 明确 `entry-filings` 接口、字段映射与 `external_id` 合并键 |
327 330
 
328 331
 ---
329 332
 

+ 35 - 9
doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理技术方案.md

@@ -36,7 +36,8 @@
36 36
 | 字段 | 类型 | 非空 | 说明 |
37 37
 | --- | --- | --- | --- |
38 38
 | `id` | `bigint(20)` | Y | 主键 |
39
-| `yak_no` | `varchar(64)` | Y | 牦牛编号,**唯一** |
39
+| `external_id` | `bigint(20)` | N | 第三方牛只 ID(`OpenYakEntryDto.cattleId`),**唯一** |
40
+| `yak_no` | `varchar(64)` | Y | 牦牛编号(优先 `earTagNumber`),**唯一** |
40 41
 | `pasture_id` | `bigint(20)` | N | 关联 `biz_pasture.id`(映射成功时) |
41 42
 | `pasture_name` | `varchar(128)` | N | 所属牧场名称(同步冗余展示) |
42 43
 | `batch_no` | `varchar(64)` | N | 批次编号 |
@@ -64,7 +65,30 @@
64 65
 | `del_flag` | `char(1)` | Y | `0` 存在 `2` 删除 |
65 66
 | `create_time` / `update_time` | datetime | — | 审计 |
66 67
 
67
-**索引**:`UNIQUE uk_yak_no (yak_no)`;`KEY idx_asset_status`;`KEY idx_pasture_id`;`KEY idx_status_change_date`;`KEY idx_last_sync_time`;`KEY idx_del_flag`。
68
+**索引**:`UNIQUE uk_external_id (external_id)`;`UNIQUE uk_yak_no (yak_no)`;`KEY idx_asset_status`;`KEY idx_pasture_id`;`KEY idx_status_change_date`;`KEY idx_last_sync_time`;`KEY idx_del_flag`。
69
+
70
+#### 2.1.1 第三方入栏建档字段映射(`OpenYakEntryDto` → `biz_yak_asset`)
71
+
72
+| OpenAPI 字段 | 库字段 | 说明 |
73
+| --- | --- | --- |
74
+| `cattleId` | `external_id` | 同步 upsert 主键 |
75
+| `earTagNumber` / `cattleNo` | `yak_no` | 优先耳标 |
76
+| `farmId` | `pasture_id` | 经 `biz_pasture.external_id` 解析 |
77
+| `farmName` | `pasture_name` | 冗余展示 |
78
+| `entryCycle` / `batchId` | `batch_no` | 优先 `entryCycle` |
79
+| `farmEnclosureName` | `pen_location` | 并写入 `biz_yak_pen_rel` |
80
+| `farmLocation` | `location` | |
81
+| `cattleSex` | `gender` | 归一公/母 |
82
+| `cattleSource` | `source` | |
83
+| `fatherNumber` | `father_yak_no` | |
84
+| `motherNumber` | `mother_yak_no` | |
85
+| `weight` | `entry_weight_kg` | 当前体重 |
86
+| `entryTime` | `entry_date` | |
87
+| `birthDate` | `birth_date` | 月龄由同步时计算写入 `age_months` |
88
+| `remark` | `status_change_reason` | |
89
+| `id` / `cattlePicture` / `registrant` / `cattleVariety` | — | 本期不落库 |
90
+
91
+列表接口未返回的字段(`asset_status`、实时体温/运动量等)保持库内原值或默认「正常」。
68 92
 
69 93
 ### 2.2 子表 `biz_yak_physio_series`(生理时序,曲线图)
70 94
 
@@ -253,13 +277,14 @@ CREATE TABLE `biz_yak_asset` (
253 277
 
254 278
 | 项 | 说明 |
255 279
 | --- | --- |
256
-| 配置 | `application.yml`:`third-party.farming`(与牧场管理同源:`base-url`、`app-key`、`app-secret`、超时) |
257
-| 客户端 | `ThirdPartyYakClient.fetchAll()` → `List<YakAssetBundleDTO>`(主表+子表) |
258
-| 映射 | `YakAssetStatusMapper`:第三方状态码 → `asset_status` |
259
-| 牧场 | 第三方牧场编码 → `biz_pasture`;失败则仅写 `pasture_name` |
260
-| 事务 | 每头牦牛一个事务:更新主表 → 删子表 → 批量插子表 |
261
-| 并发 | 同步接口加分布式锁或 `synchronized`,防止重复点击并发同步 |
262
-| 异步 | 数据量大时改 `@Async` + 轮询任务状态(可选,需求层建议) |
280
+| 配置 | `application.yml`:`third-party.farming`(与牧场管理同源) |
281
+| 接口 | `GET /open-api/v1/farming/entry-filings`;分页 `OpenApiPageResult<OpenYakEntryDto>` |
282
+| 客户端 | `ThirdPartyYakClientImpl` → `YakEntryOpenApiMapper.toBundles()` → `YakAssetBundleSyncTxService` |
283
+| 映射 | 字段对照 **§2.1.1**;`YakAssetStatusMapper` 供后续含状态字段的接口 |
284
+| 牧场 | `farmId` → `biz_pasture.external_id`;失败则仅写 `farmName` |
285
+| 事务 | 每头牦牛独立事务(`REQUIRES_NEW`);子表仅当 bundle 携带对应列表时覆盖 |
286
+| Stub | `thirdparty/stub-yak-entries.json`(OpenAPI 记录样例) |
287
+| 并发 | `synchronized` 防重复点击 |
263 288
 
264 289
 ### 3.8 查询约定
265 290
 
@@ -296,3 +321,4 @@ CREATE TABLE `biz_yak_asset` (
296 321
 | 版本 | 说明 |
297 322
 | --- | --- |
298 323
 | 1.0 | 初稿:主表+四子表;只读查询+同步接口;生长实测/预测分 `data_kind`;对接可配置 |
324
+| 1.1 | 对齐入栏建档 OpenAPI:`OpenYakEntryDto`、`external_id`、分页拉取、字段映射 **§2.1.1** |

+ 5 - 4
doc/产业数据模型及服务/牧业金融生物资产管理/牦牛资产档案管理测试用例.md

@@ -3,9 +3,9 @@
3 3
 > 依据:`牦牛资产档案管理功能需求.md`、`牦牛资产档案管理技术方案.md`  
4 4
 > **接口 Base Path(示例)**:`/dataModel/yakAsset`;若含 `context-path` 或网关前缀须补齐。鉴权与若依一致(Cookie / Token)。
5 5
 
6
-**通用前置(无特殊说明)**:具备本模块菜单与按钮权限的账号已登录;库中已有经同步或造数的档案样本(主表 `biz_yak_asset`,`del_flag=0`)。**资产状态**(`asset_status`):`1` 正常、`2` 死淘、`3` 丢失、`4` 出栏。**生长数据种类**(`data_kind`):`1` 实测、`2` 预测。详情含扩展基础字段及 `penRecords`、`batchRecords`。
6
+**通用前置(无特殊说明)**:具备本模块菜单与按钮权限的账号已登录;开发环境 `third-party.farming.mode=stub` 可使用 `stub-yak-entries.json`。库中已有经同步或造数的档案样本(主表 `biz_yak_asset`,`del_flag=0`)。**资产状态**(`asset_status`):`1` 正常、`2` 死淘、`3` 丢失、`4` 出栏。
7 7
 
8
-**样本约定(示例)**:`yakNo=YAK20250001`;`assetStatus=2`;父编号 `YAK-F001`、母编号 `YAK-M001` 且库内存在可跳转;至少一头含生理时序、生长实测+预测、繁殖、饲喂子表数据
8
+**样本约定(示例)**:`externalId=90001`,`yakNo=YAK20250001`(耳标);`farmId=1` 对应已同步牧场;父编号 `YAK-F001`、母编号 `YAK-M001`
9 9
 
10 10
 **界面(UI)测试**:**Playwright** + **Chromium**(`channel: 'chrome'` 使用本机 **Google Chrome**)。菜单路径以 `livestockFinance/yakAsset/index` 为准。
11 11
 
@@ -19,8 +19,9 @@
19 19
 | ZCZX-MYNZDA-UT-002 | 状态映射 | 非法本地状态 | 单元测试 | JUnit5 | 1~4 | 无 | `assetStatus` 为 0、5、null | 列表入参校验失败 |
20 20
 | ZCZX-MYNZDA-UT-003 | 月龄计算 | 由出生日期计算 | 单元测试 | JUnit5 | §2.4 | `birthDate=2024-01-15` | `resolveAgeMonths(固定今日)` | 月龄与约定算法一致(月差或四舍五入规则固定) |
21 21
 | ZCZX-MYNZDA-UT-004 | 列表校验 | 关键字 trim | 单元测试 | JUnit5 | 模糊查询 | 无 | `keyword` 含前后空格 | trim 后参与 `LIKE` |
22
-| ZCZX-MYNZDA-UT-005 | 同步合并 | 按 yak_no 新增 | 单元测试 | JUnit5+Mockito | §5.3 | DB 无该编号 | `syncBundle(yakNo 新)` | `insert` 主表;子表批量插入 |
23
-| ZCZX-MYNZDA-UT-006 | 同步合并 | 按 yak_no 更新 | 单元测试 | JUnit5+Mockito | §5.3 | DB 已有同编号 | `syncBundle` 字段变更 | `update` 主表;子表先删后插 |
22
+| ZCZX-MYNZDA-UT-005 | 同步合并 | 按 external_id 新增 | 单元测试 | JUnit5+Mockito | §5.3 | DB 无该 cattleId | `syncBundle` 新记录 | `insert` 主表;圈舍/批次关联写入 |
23
+| ZCZX-MYNZDA-UT-006 | 同步合并 | 按 external_id 更新 | 单元测试 | JUnit5+Mockito | §5.3 | DB 已有同 cattleId | `syncBundle` 字段变更 | `update` 主表;圈舍/批次覆盖 |
24
+| ZCZX-MYNZDA-UT-006A | OpenAPI 映射 | 入栏建档字段 | 单元测试 | JUnit5 | §2.1.1 | `OpenYakEntryDto` 样例 | `YakEntryOpenApiMapper.toBundle` | `yak_no`=耳标;`farmId`→牧场编码;字段与表一致 |
24 25
 | ZCZX-MYNZDA-UT-007 | 同步安全 | 失败不清库 | 单元测试 | JUnit5+Mockito | §5.3 | 已有档案 A | Mock 第三方抛超时;执行 sync | 档案 A 主表及子表条数不变 |
25 26
 | ZCZX-MYNZDA-UT-008 | 同步安全 | 单头失败不回滚全局 | 单元测试 | JUnit5+Mockito | 事务边界 | 批次含 2 头,第 2 头校验失败 | `syncAll` | 第 1 头成功落库;`failCount≥1`;第 1 头数据保留 |
26 27
 | ZCZX-MYNZDA-UT-009 | 牧场映射 | 映射成功写 pasture_id | 单元测试 | JUnit5+Mockito | §5.3 | `biz_pasture` 有匹配编码 | 同步带第三方牧场码 | `pasture_id` 非空;`pasture_name` 与主数据一致 |

+ 3 - 1
sql/biz_yak_asset.sql

@@ -3,7 +3,8 @@ SET NAMES utf8mb4;
3 3
 
4 4
 CREATE TABLE IF NOT EXISTS `biz_yak_asset` (
5 5
   `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
6
-  `yak_no` varchar(64) NOT NULL COMMENT '牦牛编号',
6
+  `external_id` bigint(20) DEFAULT NULL COMMENT '第三方牛只唯一ID OpenYakEntryDto.cattleId',
7
+  `yak_no` varchar(64) NOT NULL COMMENT '牦牛编号(优先耳标 earTagNumber)',
7 8
   `pasture_id` bigint(20) DEFAULT NULL COMMENT '牧场ID',
8 9
   `pasture_name` varchar(128) DEFAULT NULL COMMENT '所属牧场名称',
9 10
   `batch_no` varchar(64) DEFAULT NULL COMMENT '批次编号',
@@ -32,6 +33,7 @@ CREATE TABLE IF NOT EXISTS `biz_yak_asset` (
32 33
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
33 34
   `update_time` datetime DEFAULT NULL COMMENT '更新时间',
34 35
   PRIMARY KEY (`id`),
36
+  UNIQUE KEY `uk_external_id` (`external_id`),
35 37
   UNIQUE KEY `uk_yak_no` (`yak_no`),
36 38
   KEY `idx_asset_status` (`asset_status`),
37 39
   KEY `idx_pasture_id` (`pasture_id`),