feat(documents): единый блок «Связанные документы» на всех документах (п.5)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
GET /api/documents/{type}/{id}/related — основание + связанные (возвраты/оплаты/
опр-спис/возвраты по чеку) для supply/supplier-return/retail-sale/inventory/enter/loss.
Компонент RelatedDocsBlock применён на edit-страницах приёмки, возврата поставщику,
оприходования, списания, чека (инвентаризация имела свой блок). Integration-тест:
приёмка↔возврат (related/basedOn).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9ca907ce47
commit
3579e3583c
100
src/food-market.api/Controllers/DocumentsController.cs
Normal file
100
src/food-market.api/Controllers/DocumentsController.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Сквозные связи документов (как «Связанные документы» в МойСкладе):
|
||||||
|
/// по типу+id документа возвращает «основание» (на основании чего создан) и
|
||||||
|
/// «связанные» (что создано на основании этого / оплаты / возвраты). Единый
|
||||||
|
/// источник для блока «Связанные документы» на всех страницах документов.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/documents")]
|
||||||
|
public class DocumentsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public DocumentsController(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public record DocRef(string type, Guid id, string number, DateTime date, string? relation);
|
||||||
|
public record RelatedResult(IReadOnlyList<DocRef> basedOn, IReadOnlyList<DocRef> related);
|
||||||
|
|
||||||
|
/// <summary>type ∈ supply | supplier-return | retail-sale | inventory | enter | loss.</summary>
|
||||||
|
[HttpGet("{type}/{id:guid}/related")]
|
||||||
|
public async Task<ActionResult<RelatedResult>> Related(string type, Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var basedOn = new List<DocRef>();
|
||||||
|
var related = new List<DocRef>();
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "supply":
|
||||||
|
{
|
||||||
|
// На основании этой приёмки: возвраты поставщику + оплаты.
|
||||||
|
related.AddRange(await _db.SupplierReturns.AsNoTracking()
|
||||||
|
.Where(r => r.ReferenceSupplyId == id)
|
||||||
|
.Select(r => new DocRef("supplier-return", r.Id, r.Number, r.Date, "возврат поставщику")).ToListAsync(ct));
|
||||||
|
related.AddRange(await _db.MoneyTransactions.AsNoTracking()
|
||||||
|
.Where(t => t.Links.Any(l => l.SupplyId == id))
|
||||||
|
.Select(t => new DocRef("money", t.Id, t.Number, t.Date, "оплата")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "supplier-return":
|
||||||
|
{
|
||||||
|
var refSupplyId = await _db.SupplierReturns.AsNoTracking().Where(r => r.Id == id)
|
||||||
|
.Select(r => r.ReferenceSupplyId).FirstOrDefaultAsync(ct);
|
||||||
|
if (refSupplyId is { } rs)
|
||||||
|
basedOn.AddRange(await _db.Supplies.AsNoTracking().Where(s => s.Id == rs)
|
||||||
|
.Select(s => new DocRef("supply", s.Id, s.Number, s.Date, "приёмка")).ToListAsync(ct));
|
||||||
|
related.AddRange(await _db.MoneyTransactions.AsNoTracking()
|
||||||
|
.Where(t => t.Links.Any(l => l.SupplierReturnId == id))
|
||||||
|
.Select(t => new DocRef("money", t.Id, t.Number, t.Date, "возврат денег")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "retail-sale":
|
||||||
|
{
|
||||||
|
var refSaleId = await _db.RetailSales.AsNoTracking().Where(s => s.Id == id)
|
||||||
|
.Select(s => s.ReferenceSaleId).FirstOrDefaultAsync(ct);
|
||||||
|
if (refSaleId is { } rsi)
|
||||||
|
basedOn.AddRange(await _db.RetailSales.AsNoTracking().Where(s => s.Id == rsi)
|
||||||
|
.Select(s => new DocRef("retail-sale", s.Id, s.Number, s.Date, "чек")).ToListAsync(ct));
|
||||||
|
related.AddRange(await _db.RetailSales.AsNoTracking()
|
||||||
|
.Where(s => s.ReferenceSaleId == id)
|
||||||
|
.Select(s => new DocRef("retail-sale", s.Id, s.Number, s.Date, "возврат")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "inventory":
|
||||||
|
{
|
||||||
|
related.AddRange(await _db.Enters.AsNoTracking().Where(e => e.InventoryDocId == id)
|
||||||
|
.Select(e => new DocRef("enter", e.Id, e.Number, e.Date, "оприходование")).ToListAsync(ct));
|
||||||
|
related.AddRange(await _db.Losses.AsNoTracking().Where(l => l.InventoryDocId == id)
|
||||||
|
.Select(l => new DocRef("loss", l.Id, l.Number, l.Date, "списание")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
{
|
||||||
|
var invId = await _db.Enters.AsNoTracking().Where(e => e.Id == id).Select(e => e.InventoryDocId).FirstOrDefaultAsync(ct);
|
||||||
|
if (invId is { } iv)
|
||||||
|
basedOn.AddRange(await _db.InventoryDocs.AsNoTracking().Where(x => x.Id == iv)
|
||||||
|
.Select(x => new DocRef("inventory", x.Id, x.Number, x.Date, "инвентаризация")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "loss":
|
||||||
|
{
|
||||||
|
var invId = await _db.Losses.AsNoTracking().Where(l => l.Id == id).Select(l => l.InventoryDocId).FirstOrDefaultAsync(ct);
|
||||||
|
if (invId is { } iv)
|
||||||
|
basedOn.AddRange(await _db.InventoryDocs.AsNoTracking().Where(x => x.Id == iv)
|
||||||
|
.Select(x => new DocRef("inventory", x.Id, x.Number, x.Date, "инвентаризация")).ToListAsync(ct));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest(new { error = $"Неизвестный тип документа «{type}»." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RelatedResult(
|
||||||
|
basedOn.OrderBy(x => x.date).ToList(),
|
||||||
|
related.OrderBy(x => x.date).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/food-market.web/src/components/RelatedDocsBlock.tsx
Normal file
61
src/food-market.web/src/components/RelatedDocsBlock.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LinkIcon } from 'lucide-react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
export type DocType = 'supply' | 'supplier-return' | 'retail-sale' | 'inventory' | 'enter' | 'loss'
|
||||||
|
|
||||||
|
interface DocRef { type: string; id: string; number: string; date: string; relation: string | null }
|
||||||
|
interface Related { basedOn: DocRef[]; related: DocRef[] }
|
||||||
|
|
||||||
|
const PATH: Record<string, (id: string) => string | null> = {
|
||||||
|
'supply': (id) => `/purchases/supplies/${id}`,
|
||||||
|
'supplier-return': (id) => `/purchases/supplier-returns/${id}`,
|
||||||
|
'retail-sale': (id) => `/sales/retail/${id}`,
|
||||||
|
'inventory': (id) => `/inventory/inventories/${id}`,
|
||||||
|
'enter': (id) => `/inventory/enters/${id}`,
|
||||||
|
'loss': (id) => `/inventory/losses/${id}`,
|
||||||
|
'money': () => `/money/transactions`,
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ d }: { d: DocRef }) {
|
||||||
|
const to = PATH[d.type]?.(d.id)
|
||||||
|
const label = <>{d.relation ? <span className="text-slate-400">{d.relation}: </span> : null}<span className="font-mono">{d.number}</span> <span className="text-slate-400 text-xs">{new Date(d.date).toLocaleDateString('ru')}</span></>
|
||||||
|
return (
|
||||||
|
<li className="py-0.5 text-sm">
|
||||||
|
{to ? <Link to={to} className="text-blue-600 dark:text-blue-400 hover:underline">{label}</Link> : <span>{label}</span>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок «Связанные документы» и «На основании …» — как в инвентаризации, единый
|
||||||
|
* для всех документов (п.5 паритета). Скрывается, если связей нет.
|
||||||
|
*/
|
||||||
|
export function RelatedDocsBlock({ type, id }: { type: DocType; id?: string }) {
|
||||||
|
const { data } = useQuery<Related>({
|
||||||
|
queryKey: ['/api/documents/related', type, id],
|
||||||
|
queryFn: async () => (await api.get<Related>(`/api/documents/${type}/${id}/related`)).data,
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
if (!data || (data.basedOn.length === 0 && data.related.length === 0)) return null
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2">
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" /> Связанные документы
|
||||||
|
</div>
|
||||||
|
{data.basedOn.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-xs text-slate-400 mb-0.5">На основании</div>
|
||||||
|
<ul>{data.basedOn.map((d) => <Row key={d.id} d={d} />)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.related.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-400 mb-0.5">Создано на основании этого</div>
|
||||||
|
<ul>{data.related.map((d) => <Row key={d.id} d={d} />)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, type FormEvent } from 'react'
|
import { useState, useEffect, type FormEvent } from 'react'
|
||||||
|
import { RelatedDocsBlock } from '@/components/RelatedDocsBlock'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||||||
|
|
@ -255,6 +256,7 @@ export function EnterEditPage() {
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
||||||
|
{!isNew && id && <RelatedDocsBlock type="enter" id={id} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, type FormEvent } from 'react'
|
import { useState, useEffect, type FormEvent } from 'react'
|
||||||
|
import { RelatedDocsBlock } from '@/components/RelatedDocsBlock'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||||||
|
|
@ -258,6 +259,7 @@ export function LossEditPage() {
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
||||||
|
{!isNew && id && <RelatedDocsBlock type="loss" id={id} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { RelatedDocsBlock } from '@/components/RelatedDocsBlock'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||||
|
|
@ -301,6 +302,7 @@ export function RetailSaleEditPage() {
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
||||||
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
||||||
|
{!isNew && id && <RelatedDocsBlock type="retail-sale" id={id} />}
|
||||||
|
|
||||||
<Section title="Реквизиты чека">
|
<Section title="Реквизиты чека">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, type FormEvent } from 'react'
|
import { useState, useEffect, type FormEvent } from 'react'
|
||||||
|
import { RelatedDocsBlock } from '@/components/RelatedDocsBlock'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||||||
|
|
@ -253,6 +254,7 @@ export function SupplierReturnEditPage() {
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
<div className="max-w-5xl mx-auto p-3 sm:p-6 space-y-4">
|
||||||
|
{!isNew && id && <RelatedDocsBlock type="supplier-return" id={id} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'
|
||||||
|
import { RelatedDocsBlock } from '@/components/RelatedDocsBlock'
|
||||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||||
|
|
@ -350,6 +351,7 @@ export function SupplyEditPage() {
|
||||||
{/* Scrollable body */}
|
{/* Scrollable body */}
|
||||||
<div ref={scrollBodyRef} className="flex-1 overflow-auto">
|
<div ref={scrollBodyRef} className="flex-1 overflow-auto">
|
||||||
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4 sm:space-y-5">
|
||||||
|
{!isNew && id && <RelatedDocsBlock type="supply" id={id} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,14 @@ public async Task CreateFromSupply_presets_lines_and_links_supply()
|
||||||
lines.Should().ContainSingle(l => l.GetProperty("productId").GetString() == product);
|
lines.Should().ContainSingle(l => l.GetProperty("productId").GetString() == product);
|
||||||
lines.First().GetProperty("quantity").GetDecimal().Should().Be(10m);
|
lines.First().GetProperty("quantity").GetDecimal().Should().Be(10m);
|
||||||
lines.First().GetProperty("unitPrice").GetDecimal().Should().Be(100m);
|
lines.First().GetProperty("unitPrice").GetDecimal().Should().Be(100m);
|
||||||
|
var returnId = r.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Связи документов (п.5): приёмка видит возврат как связанный; возврат — приёмку как основание.
|
||||||
|
var supRel = await api.GetJsonAsync($"/api/documents/supply/{supplyId}/related");
|
||||||
|
supRel.GetProperty("related").EnumerateArray()
|
||||||
|
.Should().Contain(x => x.GetProperty("type").GetString() == "supplier-return" && x.GetProperty("id").GetString() == returnId);
|
||||||
|
var retRel = await api.GetJsonAsync($"/api/documents/supplier-return/{returnId}/related");
|
||||||
|
retRel.GetProperty("basedOn").EnumerateArray()
|
||||||
|
.Should().Contain(x => x.GetProperty("type").GetString() == "supply" && x.GetProperty("id").GetString() == supplyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue