291 lines
9.2 KiB
Markdown
291 lines
9.2 KiB
Markdown
# Production Surplus Lightweight Source-Lot Return Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Make 原材料库「生产余料入库」hide unnecessary free-text fields and return surplus weight against one or more selected source stock lots.
|
||
|
||
**Architecture:** Keep the existing generic `/inventory/inbound` API and existing `PRODUCTION_SURPLUS` business type. The frontend special-cases only the generic drawer when `activeOperation.bizType === "PRODUCTION_SURPLUS"`: hide batch/provider fields, replace free-text source lot input with a searchable multi-select, show per-source-lot return weight rows, and submit one inbound record per selected source lot.
|
||
|
||
**Tech Stack:** Vue 3 Composition API, existing `StockLotTagSelect.vue`, existing `fetchResource`/`postResource`, FastAPI existing generic inbound endpoint.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
- Add source-lot option state for generic production surplus inbound.
|
||
- Hide `批次号` and `来源/提供方` for `PRODUCTION_SURPLUS`.
|
||
- Replace the single text input for `来源库存批次号` with `StockLotTagSelect` for `PRODUCTION_SURPLUS`.
|
||
- Add a per-source-lot return weight table and submit one inbound request per source lot.
|
||
- No backend model/table changes.
|
||
- No new SQL.
|
||
|
||
## Task 1: Frontend State And Option Loading
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
|
||
- [ ] **Step 1: Add generic source lot state**
|
||
|
||
Add refs near the existing `productionStockLotOptions` and `selectedProductionStockLots` declarations:
|
||
|
||
```js
|
||
const genericSourceLotOptions = ref([]);
|
||
const selectedGenericSourceLots = ref([]);
|
||
let genericSourceLotOptionsRequestSeq = 0;
|
||
```
|
||
|
||
- [ ] **Step 2: Add computed helpers**
|
||
|
||
Add computed values near `genericRequiresLogistics`:
|
||
|
||
```js
|
||
const isProductionSurplusInbound = computed(() =>
|
||
activeOperation.value?.direction === "in" && activeOperation.value?.bizType === "PRODUCTION_SURPLUS"
|
||
);
|
||
const genericSourceLotReturnWeight = computed(() =>
|
||
selectedGenericSourceLots.value.reduce((total, lot) => total + Number(lot.return_weight_kg || 0), 0)
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 3: Reset selected source lots when generic form resets**
|
||
|
||
Update `resetGenericForm()` so it clears the selected lots and option list:
|
||
|
||
```js
|
||
genericSourceLotOptionsRequestSeq += 1;
|
||
selectedGenericSourceLots.value = [];
|
||
genericSourceLotOptions.value = [];
|
||
```
|
||
|
||
- [ ] **Step 4: Load source lot options by material**
|
||
|
||
Add this function near `loadProductionStockLotOptions`:
|
||
|
||
```js
|
||
async function loadGenericSourceLotOptions(materialItemId) {
|
||
const requestSeq = ++genericSourceLotOptionsRequestSeq;
|
||
const id = Number(materialItemId || 0);
|
||
if (!id || !isProductionSurplusInbound.value) {
|
||
genericSourceLotOptions.value = [];
|
||
selectedGenericSourceLots.value = [];
|
||
return;
|
||
}
|
||
try {
|
||
const rows = await fetchResource(`/inventory/stock-lot-options?material_item_id=${id}&limit=1000`, []);
|
||
if (requestSeq !== genericSourceLotOptionsRequestSeq) {
|
||
return;
|
||
}
|
||
genericSourceLotOptions.value = Array.isArray(rows) ? rows : [];
|
||
const optionMap = new Map(genericSourceLotOptions.value.map((lot) => [Number(lot.lot_id), lot]));
|
||
selectedGenericSourceLots.value = selectedGenericSourceLots.value
|
||
.filter((lot) => optionMap.has(Number(lot.lot_id)))
|
||
.map((lot) => ({
|
||
...optionMap.get(Number(lot.lot_id)),
|
||
return_weight_kg: Number(lot.return_weight_kg || 0)
|
||
}));
|
||
} catch (error) {
|
||
if (requestSeq === genericSourceLotOptionsRequestSeq) {
|
||
genericSourceLotOptions.value = [];
|
||
selectedGenericSourceLots.value = [];
|
||
errorMessage.value = error.message || "来源库存批次号加载失败";
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Watch material and operation changes**
|
||
|
||
Add watchers near the existing Vue watchers:
|
||
|
||
```js
|
||
watch(
|
||
() => [genericForm.item_id, activeOperation.value?.bizType, genericDrawerOpen.value],
|
||
async () => {
|
||
if (!genericDrawerOpen.value || !isProductionSurplusInbound.value) {
|
||
return;
|
||
}
|
||
selectedGenericSourceLots.value = [];
|
||
await loadGenericSourceLotOptions(genericForm.item_id);
|
||
}
|
||
);
|
||
|
||
watch(
|
||
selectedGenericSourceLots,
|
||
() => {
|
||
if (isProductionSurplusInbound.value) {
|
||
genericForm.weight_kg = Number(genericSourceLotReturnWeight.value.toFixed(3));
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
```
|
||
|
||
## Task 2: Frontend Template
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
|
||
- [ ] **Step 1: Hide batch/provider fields for production surplus inbound**
|
||
|
||
Change this condition:
|
||
|
||
```vue
|
||
<div v-if="activeOperation?.direction !== 'out'" class="double-field">
|
||
```
|
||
|
||
to:
|
||
|
||
```vue
|
||
<div v-if="activeOperation?.direction !== 'out' && !isProductionSurplusInbound" class="double-field">
|
||
```
|
||
|
||
- [ ] **Step 2: Hide top-level inbound weight for production surplus inbound**
|
||
|
||
Add `v-if="!isProductionSurplusInbound"` to the 入库/出库 weight label:
|
||
|
||
```vue
|
||
<label v-if="!isProductionSurplusInbound" class="form-field">
|
||
<span>{{ activeOperation?.direction === "out" ? "出库重量(kg)" : "入库重量(kg)" }}</span>
|
||
<input v-model.number="genericForm.weight_kg" type="number" min="0.001" step="0.001" required />
|
||
</label>
|
||
```
|
||
|
||
- [ ] **Step 3: Replace source-lot text input with multi-select for production surplus inbound**
|
||
|
||
Replace the existing `来源库存批次号` label with two branches:
|
||
|
||
```vue
|
||
<label v-if="!isProductionSurplusInbound" class="form-field">
|
||
<span>来源库存批次号</span>
|
||
<input v-model.trim="genericForm.source_material_sub_batch_no" type="text" placeholder="有来源库存批次时填写" />
|
||
</label>
|
||
|
||
<label v-else class="form-field">
|
||
<span>来源库存批次号</span>
|
||
<StockLotTagSelect v-model="selectedGenericSourceLots" :options="genericSourceLotOptions" placeholder="搜索并选择归还到的来源库存批次号,可多选" />
|
||
</label>
|
||
```
|
||
|
||
- [ ] **Step 4: Add return-weight table**
|
||
|
||
Add this block after the production-surplus `StockLotTagSelect` label:
|
||
|
||
```vue
|
||
<div v-if="isProductionSurplusInbound && selectedGenericSourceLots.length" class="table-wrap">
|
||
<table class="data-table compact-table">
|
||
<thead>
|
||
<tr>
|
||
<th>来源库存批次号</th>
|
||
<th>原材料</th>
|
||
<th>剩余重量</th>
|
||
<th>归还重量(kg)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="lot in selectedGenericSourceLots" :key="lot.lot_id">
|
||
<td>{{ lot.lot_no }}</td>
|
||
<td>{{ lot.item_code }} · {{ lot.item_name }}</td>
|
||
<td>{{ formatWeight(lot.remaining_weight_kg) }}</td>
|
||
<td>
|
||
<input v-model.number="lot.return_weight_kg" type="number" min="0.001" step="0.001" required />
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div v-if="isProductionSurplusInbound" class="feedback-banner">
|
||
本次归还总重量:{{ formatWeight(genericSourceLotReturnWeight) }}
|
||
</div>
|
||
```
|
||
|
||
## Task 3: Submission Logic
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
|
||
- [ ] **Step 1: Add validation for production surplus source lots**
|
||
|
||
Add this function before `submitGenericOperation()`:
|
||
|
||
```js
|
||
function validateProductionSurplusSourceLots() {
|
||
if (!isProductionSurplusInbound.value) {
|
||
return "";
|
||
}
|
||
if (!selectedGenericSourceLots.value.length) {
|
||
return "请选择至少一个来源库存批次号";
|
||
}
|
||
for (const lot of selectedGenericSourceLots.value) {
|
||
const weight = Number(lot.return_weight_kg || 0);
|
||
if (!Number.isFinite(weight) || weight <= 0) {
|
||
return `来源库存批次号 ${lot.lot_no || "-"} 的归还重量必须大于0`;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Submit one inbound record per selected source lot**
|
||
|
||
At the start of the inbound branch inside `submitGenericOperation()`, add a special case:
|
||
|
||
```js
|
||
if (isProductionSurplusInbound.value) {
|
||
const validationError = validateProductionSurplusSourceLots();
|
||
if (validationError) {
|
||
errorMessage.value = validationError;
|
||
submittingOperation.value = false;
|
||
return;
|
||
}
|
||
for (const lot of selectedGenericSourceLots.value) {
|
||
await postResource("/inventory/inbound", {
|
||
biz_type: activeOperation.value.bizType,
|
||
item_id: Number(genericForm.item_id),
|
||
warehouse_id: Number(genericForm.warehouse_id),
|
||
location_id: genericForm.location_id ? Number(genericForm.location_id) : null,
|
||
inbound_weight_kg: Number(lot.return_weight_kg || 0),
|
||
unit_cost: Number(genericForm.unit_cost || 0),
|
||
lot_no: null,
|
||
source_material_sub_batch_no: lot.lot_no || null,
|
||
source_material_summary: lot.lot_no || null,
|
||
provider_name: null,
|
||
waybill_no: null,
|
||
order_photo_url: null,
|
||
freight_amount: null,
|
||
remark: genericForm.remark || null
|
||
});
|
||
}
|
||
feedbackMessage.value = `${activeOperation.value.label}已登记`;
|
||
genericDrawerOpen.value = false;
|
||
await loadAll();
|
||
return;
|
||
}
|
||
```
|
||
|
||
## Task 4: Verification
|
||
|
||
**Files:**
|
||
- Verify existing frontend/backend.
|
||
|
||
- [ ] **Step 1: Build frontend**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: build succeeds. Vite chunk size warning is acceptable if present.
|
||
|
||
- [ ] **Step 2: Run backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests -q
|
||
```
|
||
|
||
Expected: all existing backend tests pass. No new backend behavior is introduced.
|