feat: 打通出库作业链路

This commit is contained in:
zgw
2026-06-24 09:13:18 +08:00
parent a3c023a8d3
commit 1af5b3cbd3
141 changed files with 17337 additions and 1065 deletions

View File

@@ -0,0 +1,857 @@
# Stock Master Detail And API Log Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split inventory into master/detail storage, fix completed inbound job child-detail lookup, and persist inbound/outbound external API logs in `bas_api_log` with 30-day retention.
**Architecture:** Use a compatibility migration for inventory: keep `bas_stock` as the master/current compatibility table during this phase, add `bas_stock_detail` for material lines, and update new backend/frontend screens to read master/detail. Stored procedure stock posting writes both master and detail so existing outbound, stocktake, and dashboard readers do not break while the UI moves to the new structure. API logs use an `@ApiLog` annotation for inbound external callbacks and the existing `BaseExternalClient` choke point for outbound calls, both writing to the same table.
**Tech Stack:** SQL Server, Spring Boot, MyBatis XML, RuoYi controllers/services, Vue2 + Element UI, UniApp PDA where relevant.
---
## File Structure
**Database scripts**
- Create: `sql/bas_stock_master_detail.sql` — creates `bas_stock_detail`, backfills existing stock material lines, adds indexes.
- Create: `sql/proc_complete_in_job_stock_detail.sql` — updates `proc_complete_in_job` stock insertion section so inbound completion writes `bas_stock` and `bas_stock_detail`.
- Create: `sql/bas_api_log.sql` — creates `bas_api_log`, indexes, and cleanup procedure `proc_clean_bas_api_log`.
**Stock backend**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/domain/BasStockDetail.java` — material-line inventory detail entity.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/mapper/BasStockDetailMapper.java` — detail mapper contract.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/service/IBasStockDetailService.java` — detail service contract.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/service/impl/BasStockDetailServiceImpl.java` — detail service implementation.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/controller/BasStockDetailController.java` — `/bas/stockdetail/list` endpoint for detail rows under a master `stockId`.
- Create: `ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml` — detail SQL mapping.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/bas/stock/domain/BasStock.java` — add `List<BasStockDetail> details` for master/detail responses while keeping legacy query/export fields for compatibility.
- Modify: `ruoyi-admin/src/main/resources/mapper/bas/BasStockMapper.xml` — make main list return one master stock row per `stock_id`; stop using product fields as master columns in the main list.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/bas/stock/service/impl/BasStockServiceImpl.java` — insert/update/delete master and detail consistently for manual stock edits.
**Stock frontend**
- Create: `ruoyi-ui/src/api/bas/stockDetail.js` — stock detail API calls.
- Modify: `ruoyi-ui/src/views/bas/stock/index.vue` — show master inventory in the top table and a material-detail table below the selected master row.
**Completed inbound job detail fix**
- Modify: `ruoyi-admin/src/main/resources/mapper/in/MydoJobDetailMapper.xml` — make `selectMydoJobDetailFinishList` filter by `job_id`, mirroring the active detail query.
- Modify: `ruoyi-ui/src/views/in/job/index.vue` — pass selected row status to detail query instead of relying on the global query status.
**API logging**
- Create: `ruoyi-common/src/main/java/com/ruoyi/common/annotation/ApiLog.java` — `@Log`-style annotation.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/domain/ApiLog.java` — add direction, request method, source IP, trace ID fields.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/mapper/ApiLogMapper.java` — persistence contract.
- Create: `ruoyi-admin/src/main/resources/mapper/api/ApiLogMapper.xml` — insert and retention SQL.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/ApiLogService.java` — persist to mapper instead of only printing local logs.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/aspect/ApiLogAspect.java` — annotation aspect for inbound external callbacks.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java` — annotate WCS/ERP/MES external endpoints that are called by outside systems.
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/client/BaseExternalClient.java` — ensure outbound logs include `direction=OUTBOUND` and write to `bas_api_log`.
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/task/ApiLogCleanupTask.java` — daily retention cleanup for logs older than 30 days.
---
### Task 1: Create Stock Detail Table And Backfill Data
**Files:**
- Create: `sql/bas_stock_master_detail.sql`
- [ ] **Step 1: Add migration SQL**
Create `sql/bas_stock_master_detail.sql` with this content:
```sql
IF OBJECT_ID(N'dbo.bas_stock_detail', N'U') IS NULL
BEGIN
CREATE TABLE dbo.bas_stock_detail (
stock_detail_id varchar(80) NOT NULL,
stock_id nvarchar(32) NOT NULL,
product_status nvarchar(2) NULL,
lot_no nvarchar(80) NULL,
product_id nvarchar(80) NULL,
product_name nvarchar(255) NULL,
quantity decimal(18, 0) NULL,
out_quantity decimal(18, 0) NULL,
job_detail_id nvarchar(255) NULL,
stockin_detail_id nvarchar(80) NULL,
create_time datetime2(7) NULL CONSTRAINT DF_bas_stock_detail_create_time DEFAULT GETDATE(),
update_time datetime2(7) NULL,
CONSTRAINT PK_bas_stock_detail PRIMARY KEY CLUSTERED (stock_detail_id)
);
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.bas_stock_detail') AND name = N'IX_bas_stock_detail_stock_id')
CREATE INDEX IX_bas_stock_detail_stock_id ON dbo.bas_stock_detail(stock_id);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.bas_stock_detail') AND name = N'IX_bas_stock_detail_product')
CREATE INDEX IX_bas_stock_detail_product ON dbo.bas_stock_detail(product_id, lot_no) INCLUDE(stock_id, quantity, out_quantity);
GO
INSERT INTO dbo.bas_stock_detail (
stock_detail_id, stock_id, product_status, lot_no, product_id, product_name,
quantity, out_quantity, job_detail_id, stockin_detail_id, create_time
)
SELECT
CAST(bs.stock_id AS varchar(80)) + '-1',
bs.stock_id,
bs.product_status,
bs.lot_no,
bs.product_id,
bs.product_name,
bs.quantity,
bs.out_quantity,
bs.job_detail_id,
bs.stockin_detail_id,
ISNULL(bs.in_date, GETDATE())
FROM dbo.bas_stock bs
WHERE NOT EXISTS (
SELECT 1 FROM dbo.bas_stock_detail d WHERE d.stock_id = bs.stock_id
)
AND (bs.product_id IS NOT NULL OR bs.product_name IS NOT NULL OR bs.quantity IS NOT NULL);
GO
```
- [ ] **Step 2: Apply migration to `FTC25009-WMS`**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File ".\scripts\apply-sql-file.ps1" -SqlFile ".\sql\bas_stock_master_detail.sql"
```
Expected output:
```text
Applied SQL file to FTC25009-WMS
```
- [ ] **Step 3: Verify backfill**
Run through MCP `read_data`:
```sql
SELECT TOP 10 stock_id, COUNT(*) AS detail_count
FROM dbo.bas_stock_detail
GROUP BY stock_id
ORDER BY stock_id;
```
Expected: existing `bas_stock.stock_id` values have one `bas_stock_detail` row each.
---
### Task 2: Add Stock Detail Backend CRUD Read Path
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/domain/BasStockDetail.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/mapper/BasStockDetailMapper.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/service/IBasStockDetailService.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/service/impl/BasStockDetailServiceImpl.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/bas/stockdetail/controller/BasStockDetailController.java`
- Create: `ruoyi-admin/src/main/resources/mapper/bas/BasStockDetailMapper.xml`
- [ ] **Step 1: Create `BasStockDetail` domain**
```java
package com.ruoyi.bas.stockdetail.domain;
import java.math.BigDecimal;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
public class BasStockDetail extends BaseEntity {
private static final long serialVersionUID = 1L;
private String stockDetailId;
private String stockId;
@Excel(name = "物料状态")
private String productStatus;
@Excel(name = "批次号")
private String lotNo;
@Excel(name = "物料编码")
private String productId;
@Excel(name = "物料名称")
private String productName;
@Excel(name = "库存数量")
private BigDecimal quantity;
@Excel(name = "出库数量")
private BigDecimal outQuantity;
@Excel(name = "子作业id")
private String jobDetailId;
@Excel(name = "入库子单id")
private String stockinDetailId;
public String getStockDetailId() { return stockDetailId; }
public void setStockDetailId(String stockDetailId) { this.stockDetailId = stockDetailId; }
public String getStockId() { return stockId; }
public void setStockId(String stockId) { this.stockId = stockId; }
public String getProductStatus() { return productStatus; }
public void setProductStatus(String productStatus) { this.productStatus = productStatus; }
public String getLotNo() { return lotNo; }
public void setLotNo(String lotNo) { this.lotNo = lotNo; }
public String getProductId() { return productId; }
public void setProductId(String productId) { this.productId = productId; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public BigDecimal getOutQuantity() { return outQuantity; }
public void setOutQuantity(BigDecimal outQuantity) { this.outQuantity = outQuantity; }
public String getJobDetailId() { return jobDetailId; }
public void setJobDetailId(String jobDetailId) { this.jobDetailId = jobDetailId; }
public String getStockinDetailId() { return stockinDetailId; }
public void setStockinDetailId(String stockinDetailId) { this.stockinDetailId = stockinDetailId; }
}
```
- [ ] **Step 2: Create mapper interface**
```java
package com.ruoyi.bas.stockdetail.mapper;
import java.util.List;
import com.ruoyi.bas.stockdetail.domain.BasStockDetail;
public interface BasStockDetailMapper {
List<BasStockDetail> selectBasStockDetailList(BasStockDetail basStockDetail);
BasStockDetail selectBasStockDetailByStockDetailId(String stockDetailId);
int insertBasStockDetail(BasStockDetail basStockDetail);
int updateBasStockDetail(BasStockDetail basStockDetail);
int deleteBasStockDetailByStockDetailId(String stockDetailId);
int deleteBasStockDetailByStockIds(String[] stockIds);
}
```
- [ ] **Step 3: Create mapper XML**
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.bas.stockdetail.mapper.BasStockDetailMapper">
<resultMap type="BasStockDetail" id="BasStockDetailResult">
<result property="stockDetailId" column="stock_detail_id" />
<result property="stockId" column="stock_id" />
<result property="productStatus" column="product_status" />
<result property="lotNo" column="lot_no" />
<result property="productId" column="product_id" />
<result property="productName" column="product_name" />
<result property="quantity" column="quantity" />
<result property="outQuantity" column="out_quantity" />
<result property="jobDetailId" column="job_detail_id" />
<result property="stockinDetailId" column="stockin_detail_id" />
</resultMap>
<select id="selectBasStockDetailList" parameterType="BasStockDetail" resultMap="BasStockDetailResult">
select stock_detail_id, stock_id, product_status, lot_no, product_id, product_name,
quantity, out_quantity, job_detail_id, stockin_detail_id
from bas_stock_detail
<where>
<if test="stockId != null and stockId != ''">and stock_id = #{stockId}</if>
<if test="productId != null and productId != ''">and product_id = #{productId}</if>
<if test="productName != null and productName != ''">and product_name like concat('%', #{productName}, '%')</if>
<if test="lotNo != null and lotNo != ''">and lot_no = #{lotNo}</if>
</where>
order by stock_detail_id
</select>
<select id="selectBasStockDetailByStockDetailId" parameterType="String" resultMap="BasStockDetailResult">
select stock_detail_id, stock_id, product_status, lot_no, product_id, product_name,
quantity, out_quantity, job_detail_id, stockin_detail_id
from bas_stock_detail
where stock_detail_id = #{stockDetailId}
</select>
<insert id="insertBasStockDetail" parameterType="BasStockDetail">
insert into bas_stock_detail(stock_detail_id, stock_id, product_status, lot_no, product_id, product_name, quantity, out_quantity, job_detail_id, stockin_detail_id)
values(#{stockDetailId}, #{stockId}, #{productStatus}, #{lotNo}, #{productId}, #{productName}, #{quantity}, #{outQuantity}, #{jobDetailId}, #{stockinDetailId})
</insert>
<update id="updateBasStockDetail" parameterType="BasStockDetail">
update bas_stock_detail
<trim prefix="set" suffixOverrides=",">
<if test="productStatus != null">product_status = #{productStatus},</if>
<if test="lotNo != null">lot_no = #{lotNo},</if>
<if test="productId != null">product_id = #{productId},</if>
<if test="productName != null">product_name = #{productName},</if>
<if test="quantity != null">quantity = #{quantity},</if>
<if test="outQuantity != null">out_quantity = #{outQuantity},</if>
<if test="jobDetailId != null">job_detail_id = #{jobDetailId},</if>
<if test="stockinDetailId != null">stockin_detail_id = #{stockinDetailId},</if>
update_time = getdate(),
</trim>
where stock_detail_id = #{stockDetailId}
</update>
<delete id="deleteBasStockDetailByStockDetailId" parameterType="String">
delete from bas_stock_detail where stock_detail_id = #{stockDetailId}
</delete>
<delete id="deleteBasStockDetailByStockIds" parameterType="String">
delete from bas_stock_detail where stock_id in
<foreach item="stockId" collection="array" open="(" separator="," close=")">
#{stockId}
</foreach>
</delete>
</mapper>
```
- [ ] **Step 4: Create service and controller using existing RuoYi pattern**
Controller endpoint must be:
```java
@PreAuthorize("@ss.hasPermi('bas:stock:list')")
@GetMapping("/list")
public TableDataInfo list(BasStockDetail basStockDetail) {
startPage();
List<BasStockDetail> list = basStockDetailService.selectBasStockDetailList(basStockDetail);
return getDataTable(list);
}
```
- [ ] **Step 5: Run IDE lints**
Run lints on the new package and fix imports/package names until there are no diagnostics.
---
### Task 3: Convert Stock UI To Master/Detail
**Files:**
- Create: `ruoyi-ui/src/api/bas/stockDetail.js`
- Modify: `ruoyi-ui/src/views/bas/stock/index.vue`
- [ ] **Step 1: Add detail API**
```js
import request from '@/utils/request'
export function listStockDetail(query) {
return request({
url: '/bas/stockdetail/list',
method: 'get',
params: query
})
}
```
- [ ] **Step 2: Update stock page row-click behavior**
In `ruoyi-ui/src/views/bas/stock/index.vue`, import:
```js
import { listStockDetail } from '@/api/bas/stockDetail'
```
Add state:
```js
currentStock: null,
stockDetailList: [],
stockDetailLoading: false,
stockDetailTotal: 0,
stockDetailQueryParams: {
pageNum: 1,
pageSize: 10,
stockId: null
}
```
Add methods:
```js
handleStockRowClick(row) {
this.currentStock = row
this.stockDetailQueryParams.pageNum = 1
this.stockDetailQueryParams.stockId = row.stockId
this.getStockDetailList()
},
getStockDetailList() {
if (!this.stockDetailQueryParams.stockId) {
this.stockDetailList = []
this.stockDetailTotal = 0
return
}
this.stockDetailLoading = true
listStockDetail(this.stockDetailQueryParams).then(response => {
this.stockDetailList = response.rows || []
this.stockDetailTotal = response.total || 0
this.stockDetailLoading = false
}).catch(() => {
this.stockDetailList = []
this.stockDetailTotal = 0
this.stockDetailLoading = false
})
}
```
- [ ] **Step 3: Adjust top table to show only master fields**
Top table columns should keep:
- `stockId`
- `cargoSpaceId`
- `stockStatus`
- `trayCode`
- `stockinId`
- `inType`
- `warehouseName`
- `userName`
- `deptName`
- `suplierName`
Move material columns to the lower detail table:
- `productId`
- `productName`
- `lotNo`
- `quantity`
- `outQuantity`
- `jobDetailId`
- `stockinDetailId`
- [ ] **Step 4: Verify manually**
Open inventory page, click a master stock row, and confirm detail rows load from `/bas/stockdetail/list?stockId=...`.
---
### Task 4: Make Inbound Completion Write Stock Master And Detail
**Files:**
- Create: `sql/proc_complete_in_job_stock_detail.sql`
- [ ] **Step 1: Replace stock insertion block inside `proc_complete_in_job`**
Use the current procedure definition from `FTC25009-WMS`. In the cursor loop, replace the existing single `INSERT INTO bas_stock (...) VALUES (...)` block with this block:
```sql
-- 插入库存主表。主表只保存托盘、货位、仓库、入库来源等通用信息。
IF NOT EXISTS (SELECT 1 FROM dbo.bas_stock WHERE stock_id = @stock_id)
BEGIN
INSERT INTO dbo.bas_stock (
stock_id, cargo_space_id, stock_status, in_date,
job_id, tray_code, stockin_id, in_type, warehouse_id
)
VALUES (
@stock_id, @cargo_space_id, '1', CONVERT(VARCHAR, GETDATE(), 120),
@job_id, @tray_code, @stockin_id, NULL, @warehouse_id
);
END;
-- 插入库存子表。子表保存物料、批次、数量和来源子单。
INSERT INTO dbo.bas_stock_detail (
stock_detail_id, stock_id, product_status, lot_no, product_id, product_name,
quantity, out_quantity, job_detail_id, stockin_detail_id, create_time
)
VALUES (
@stock_id + '-' + CAST(ABS(CHECKSUM(@job_detail_id)) AS varchar(20)),
@stock_id,
'1',
@lot_no,
@product_id,
@product_name,
@in_quantity,
0,
@job_detail_id,
@stockin_detail_id,
GETDATE()
);
```
- [ ] **Step 2: Keep legacy fields during this phase**
Do not drop old material columns from `bas_stock` in this task. Existing outbound allocation, stocktake, dashboard, and reports still contain direct references to `bas_stock.product_id`, `bas_stock.quantity`, and related fields. Remove those only after all readers are moved to `bas_stock_detail`.
- [ ] **Step 3: Apply the procedure script**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File ".\scripts\apply-sql-file.ps1" -SqlFile ".\sql\proc_complete_in_job_stock_detail.sql"
```
- [ ] **Step 4: Verify with a controlled inbound completion**
After completing one inbound job, run:
```sql
SELECT TOP 5 s.stock_id, s.cargo_space_id, s.tray_code, s.warehouse_id,
d.product_id, d.product_name, d.quantity, d.job_detail_id, d.stockin_detail_id
FROM dbo.bas_stock s
INNER JOIN dbo.bas_stock_detail d ON d.stock_id = s.stock_id
ORDER BY d.create_time DESC;
```
Expected: one master stock row with one or more detail material rows.
---
### Task 5: Fix Completed Inbound Job Detail Lookup
**Files:**
- Modify: `ruoyi-admin/src/main/resources/mapper/in/MydoJobDetailMapper.xml`
- Modify: `ruoyi-ui/src/views/in/job/index.vue`
- [ ] **Step 1: Fix finished detail SQL**
Replace `selectMydoJobDetailFinishList` with:
```xml
<select id="selectMydoJobDetailFinishList" parameterType="MydoJobDetail" resultMap="MydoJobDetailResult">
<include refid="selectMydoJobDetailFinishVo"/>
<where>
<if test="jobId != null and jobId != ''"> and job_id = #{jobId}</if>
<if test="stockId != null and stockId != ''"> and stock_id = #{stockId}</if>
<if test="lotNo != null and lotNo != ''"> and lot_no = #{lotNo}</if>
<if test="inDate != null and inDate != ''"> and in_date = #{inDate}</if>
<if test="inOrOutId != null and inOrOutId != ''"> and in_or_out_id = #{inOrOutId}</if>
<if test="inOrOutDId != null and inOrOutDId != ''"> and in_or_out_d_id = #{inOrOutDId}</if>
<if test="productId != null and productId != ''"> and product_id = #{productId}</if>
<if test="productName != null and productName != ''"> and product_name like concat('%', #{productName}, '%')</if>
<if test="inQuantity != null "> and in_quantity = #{inQuantity}</if>
<if test="stockQuantity != null "> and stock_quantity = #{stockQuantity}</if>
<if test="outQuantity != null "> and out_quantity = #{outQuantity}</if>
<if test="customerName != null and customerName != ''"> and customer_name like concat('%', #{customerName}, '%')</if>
<if test="supplierId != null and supplierId != ''"> and supplier_id = #{supplierId}</if>
<if test="supplierName != null and supplierName != ''"> and supplier_name like concat('%', #{supplierName}, '%')</if>
</where>
</select>
```
- [ ] **Step 2: Pass selected row status to child query**
In `ruoyi-ui/src/views/in/job/index.vue`, update `handleRowClick(row)` so it sets:
```js
this.detailQueryParams.jobId = row.jobId
this.detailQueryParams.status = row.status
```
Do not use `this.queryParams.status` for child query routing because the selected row is the source of truth.
- [ ] **Step 3: Verify active and completed jobs**
Use the 入库作业管理 page:
- query active job, click row, child details should load from `mydo_job_detail`.
- query status `4`, click row, child details should load from `mydo_job_detail_finish`.
---
### Task 6: Create `bas_api_log` Table And Retention Procedure
**Files:**
- Create: `sql/bas_api_log.sql`
- [ ] **Step 1: Add API log table SQL**
```sql
IF OBJECT_ID(N'dbo.bas_api_log', N'U') IS NULL
BEGIN
CREATE TABLE dbo.bas_api_log (
id bigint IDENTITY(1,1) NOT NULL,
direction varchar(16) NOT NULL,
sys_code varchar(50) NULL,
interface_name nvarchar(100) NULL,
request_url nvarchar(500) NULL,
request_method varchar(20) NULL,
request_body nvarchar(max) NULL,
response_body nvarchar(max) NULL,
msg_type varchar(20) NULL,
http_status int NULL,
cost_ms bigint NULL,
status varchar(30) NULL,
error_msg nvarchar(2000) NULL,
source_ip varchar(80) NULL,
trace_id varchar(80) NULL,
create_time datetime2(7) NOT NULL CONSTRAINT DF_bas_api_log_create_time DEFAULT GETDATE(),
CONSTRAINT PK_bas_api_log PRIMARY KEY CLUSTERED(id)
);
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.bas_api_log') AND name = N'IX_bas_api_log_create_time')
CREATE INDEX IX_bas_api_log_create_time ON dbo.bas_api_log(create_time);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'dbo.bas_api_log') AND name = N'IX_bas_api_log_direction_sys')
CREATE INDEX IX_bas_api_log_direction_sys ON dbo.bas_api_log(direction, sys_code, create_time DESC);
GO
CREATE OR ALTER PROCEDURE dbo.proc_clean_bas_api_log
AS
BEGIN
SET NOCOUNT ON;
DELETE FROM dbo.bas_api_log
WHERE create_time < DATEADD(DAY, -30, GETDATE());
END
GO
```
- [ ] **Step 2: Apply SQL**
Run:
```powershell
powershell -ExecutionPolicy Bypass -File ".\scripts\apply-sql-file.ps1" -SqlFile ".\sql\bas_api_log.sql"
```
Expected output:
```text
Applied SQL file to FTC25009-WMS
```
---
### Task 7: Persist API Logs
**Files:**
- Create: `ruoyi-common/src/main/java/com/ruoyi/common/annotation/ApiLog.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/domain/ApiLog.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/mapper/ApiLogMapper.java`
- Create: `ruoyi-admin/src/main/resources/mapper/api/ApiLogMapper.xml`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/service/ApiLogService.java`
- [ ] **Step 1: Add annotation**
```java
package com.ruoyi.common.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApiLog {
String interfaceName() default "";
String sysCode() default "";
String direction() default "INBOUND";
boolean isSaveRequestData() default true;
boolean isSaveResponseData() default true;
String[] excludeParamNames() default {};
}
```
- [ ] **Step 2: Add mapper**
```java
package com.ruoyi.api.mapper;
import com.ruoyi.api.domain.ApiLog;
public interface ApiLogMapper {
int insertApiLog(ApiLog apiLog);
int deleteExpiredApiLog();
}
```
- [ ] **Step 3: Add mapper XML**
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.api.mapper.ApiLogMapper">
<insert id="insertApiLog" parameterType="com.ruoyi.api.domain.ApiLog">
insert into bas_api_log(direction, sys_code, interface_name, request_url, request_method,
request_body, response_body, msg_type, http_status, cost_ms, status, error_msg,
source_ip, trace_id, create_time)
values(#{direction}, #{sysCode}, #{interfaceName}, #{requestUrl}, #{requestMethod},
#{requestBody}, #{responseBody}, #{msgType}, #{httpStatus}, #{costMs}, #{status}, #{errorMsg},
#{sourceIp}, #{traceId}, getdate())
</insert>
<delete id="deleteExpiredApiLog">
delete from bas_api_log where create_time &lt; dateadd(day, -30, getdate())
</delete>
</mapper>
```
- [ ] **Step 4: Change service to persist**
Inject `ApiLogMapper` into `ApiLogService`, truncate request/response/error to safe sizes, and call `apiLogMapper.insertApiLog(apiLog)` in `record(ApiLog apiLog)`.
Concrete core implementation:
```java
@Autowired
private ApiLogMapper apiLogMapper;
public void record(ApiLog apiLog) {
if (apiLog == null) {
return;
}
apiLog.setRequestBody(limit(apiLog.getRequestBody(), MAX_BODY_LEN));
apiLog.setResponseBody(limit(apiLog.getResponseBody(), MAX_BODY_LEN));
apiLog.setErrorMsg(limit(apiLog.getErrorMsg(), 2000));
apiLog.setCreateTime(LocalDateTime.now());
apiLogMapper.insertApiLog(apiLog);
}
public int cleanExpired() {
return apiLogMapper.deleteExpiredApiLog();
}
private String limit(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength) + "...[truncated]";
}
```
---
### Task 8: Add API Log Aspect And Retention Task
**Files:**
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/aspect/ApiLogAspect.java`
- Create: `ruoyi-admin/src/main/java/com/ruoyi/api/task/ApiLogCleanupTask.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/controller/ExternalApiController.java`
- Modify: `ruoyi-admin/src/main/java/com/ruoyi/api/client/BaseExternalClient.java`
- [ ] **Step 1: Add aspect for inbound external API methods**
Implement `ApiLogAspect` in `ruoyi-admin` rather than `ruoyi-framework`, because the aspect needs compile-time access to `com.ruoyi.api.service.ApiLogService`.
The aspect must:
- start timer in `@Before("@annotation(apiLog)")`
- on return, save `status=SUCCESS`
- on exception, save `status=FAIL`
- fill `direction`, `sysCode`, `interfaceName`, URL, method, source IP, request JSON, response JSON, error, cost.
- [ ] **Step 2: Annotate inbound WCS callbacks**
In `ExternalApiController`, annotate at least:
```java
@ApiLog(interfaceName = "WCS-请求入库货位", sysCode = "WCS", direction = "INBOUND")
@PostMapping("/wcs/inbound/location")
public AjaxResult inboundLocation(@RequestBody WcsInboundLocationRequest request) { ... }
@ApiLog(interfaceName = "WCS-完成入库作业", sysCode = "WCS", direction = "INBOUND")
@PostMapping("/wcs/inbound/complete")
public AjaxResult completeInbound(@RequestBody WcsInboundCompleteRequest request) { ... }
```
- [ ] **Step 3: Ensure outbound logs write to `bas_api_log`**
In `BaseExternalClient.post(...)`, keep the existing log construction but set:
```java
apiLog.setDirection("OUTBOUND");
apiLog.setRequestMethod("POST");
apiLog.setSourceIp(null);
apiLogService.record(apiLog);
```
- [ ] **Step 4: Add cleanup task**
```java
package com.ruoyi.api.task;
import com.ruoyi.api.service.ApiLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ApiLogCleanupTask {
@Autowired
private ApiLogService apiLogService;
@Scheduled(cron = "0 30 2 * * ?")
public void cleanExpiredApiLogs() {
apiLogService.cleanExpired();
}
}
```
If this project does not enable Spring scheduling, register a RuoYi Quartz job that invokes `apiLogCleanupTask.cleanExpiredApiLogs` daily at 02:30.
---
### Task 9: Verification
**Files:**
- No code files, verification only.
- [ ] **Step 1: Verify database schema**
Run:
```sql
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME IN ('bas_stock', 'bas_stock_detail', 'bas_api_log');
```
Expected: all three names returned.
- [ ] **Step 2: Verify completed job detail lookup**
Run:
```sql
SELECT TOP 1 job_id FROM dbo.mydo_job_finish ORDER BY finish_time DESC;
```
Then call:
```text
GET /in/jobdetail/list?jobId=<job_id>&status=4
```
Expected: rows from `mydo_job_detail_finish` for that `job_id`.
- [ ] **Step 3: Verify API log insert**
Call WCS inbound location or complete endpoint, then run:
```sql
SELECT TOP 10 direction, sys_code, interface_name, status, create_time
FROM dbo.bas_api_log
ORDER BY id DESC;
```
Expected: `INBOUND/WCS` rows for WCS callbacks and `OUTBOUND/WCS` rows for WMS calling WCS.
- [ ] **Step 4: Run backend build**
Run:
```powershell
mvn -pl ruoyi-admin -am -DskipTests compile
```
Expected: PASS after Maven is upgraded to at least `3.6.3`. Current local environment previously failed before Java compilation because `maven-compiler-plugin:3.13.0` requires Maven `3.6.3`.
- [ ] **Step 5: Run frontend build**
Run:
```powershell
npm run build:prod
```
from `ruoyi-ui`.
Expected: production bundle builds successfully.
---
## Self-Review
**Spec coverage:**
- Inventory master/detail database, backend, and frontend are covered in Tasks 1-4.
- Completed inbound job detail lookup is covered in Task 5.
- API log annotation, persistence to `bas_api_log`, outbound/inbound coverage, and 30-day retention are covered in Tasks 6-8.
**Known implementation guardrail:**
- Do not drop material columns from `bas_stock` in this iteration. Existing outbound allocation, stocktake, dashboard, and report SQL still read the old flat shape. This plan moves the UI/backend to master/detail while preserving compatibility; a later cleanup can remove old columns after all readers are migrated.
**Type consistency:**
- Java uses `BasStockDetail.stockDetailId`, SQL uses `stock_detail_id`.
- API log Java uses camelCase fields mapping to snake_case columns in MyBatis.