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

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:
nns 2026-10-09 07:06:07 +05:00
parent 9ca907ce47
commit 3579e3583c
8 changed files with 180 additions and 0 deletions

View 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());
}
}

View 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>
)
}

View file

@ -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>
)} )}

View file

@ -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>
)} )}

View file

@ -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">

View file

@ -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>
)} )}

View file

@ -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>
)} )}

View file

@ -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);
} }
} }