feat(import): Abgabeverträge als SaleContract-Datensätze importieren
Die ~1095 geparsten Verträge werden jetzt als echte SaleContract-Records erzeugt, sodass die Verträge-Seite gefüllt ist (statt 0). enrich_from_contracts() liefert zusätzlich eine saleContracts-Liste (deterministische Id aus Dateiname, Käufer- ContactId, Preis, Abgabe-/Vertragsdatum, FileName, gematchte Tier-Ids); main() schreibt sie als Top-Level-Key in resolved_import.json. IngestResolvedService legt SaleContract + SaleContractAnimal an (FK-sicher nach Kontakten/Tieren, unbekannte Links übersprungen) — Tabelle wird wie gehabt gewischt und aus dem Payload neu befüllt (idempotent). Ergebnis: 1033 Verträge (611 mit ≥1 Tier, alle datiert; 54 Dateinamen-Dubletten zusammengefasst, 7 datumlose + 1 ohne Käufer übersprungen). Kein Schema-Change (datumlose übersprungen statt Spalten nullable → keine Migration). DOCX-Download: importierte Verträge haben keine Word-Datei im contract-storage → DTO.HasFile=false, Frontend blendet den Word-Button aus (Hinweis „Keine Word- Datei"), PDF wird weiterhin generiert. Download-Endpoint liefert sauber 404 statt 500 bei fehlender Datei. Tests: dotnet 213, vitest 129, playwright 16 (neuer vertraege.spec.ts), Python grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ namespace GerbilManager.Tests
|
|||||||
{
|
{
|
||||||
private readonly string _dir;
|
private readonly string _dir;
|
||||||
private readonly string _resolvedJsonPath;
|
private readonly string _resolvedJsonPath;
|
||||||
|
private Guid ContactId { get; set; }
|
||||||
|
private Guid FatherId { get; set; }
|
||||||
|
private Guid ContractId { get; set; }
|
||||||
|
private Guid UnknownGerbilId { get; set; }
|
||||||
|
|
||||||
public IngestResolvedServiceTests()
|
public IngestResolvedServiceTests()
|
||||||
{
|
{
|
||||||
@@ -22,6 +26,10 @@ namespace GerbilManager.Tests
|
|||||||
var motherId = Guid.NewGuid();
|
var motherId = Guid.NewGuid();
|
||||||
var litterId = Guid.NewGuid();
|
var litterId = Guid.NewGuid();
|
||||||
var photoId = Guid.NewGuid();
|
var photoId = Guid.NewGuid();
|
||||||
|
ContactId = contactId;
|
||||||
|
FatherId = fatherId;
|
||||||
|
ContractId = Guid.NewGuid();
|
||||||
|
UnknownGerbilId = Guid.NewGuid();
|
||||||
|
|
||||||
var data = new
|
var data = new
|
||||||
{
|
{
|
||||||
@@ -127,6 +135,20 @@ namespace GerbilManager.Tests
|
|||||||
FileName = "photo1.jpg",
|
FileName = "photo1.jpg",
|
||||||
SortOrder = 0
|
SortOrder = 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
SaleContracts = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = ContractId,
|
||||||
|
ContactId = contactId,
|
||||||
|
Price = 35.0m,
|
||||||
|
HandoverDate = "2023-09-29",
|
||||||
|
ContractDate = "2023-09-29",
|
||||||
|
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
||||||
|
// One known animal + one unknown id (must be skipped defensively).
|
||||||
|
Animals = new[] { fatherId, UnknownGerbilId }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +221,41 @@ namespace GerbilManager.Tests
|
|||||||
Assert.NotNull(litter.Provenance);
|
Assert.NotNull(litter.Provenance);
|
||||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||||
|
|
||||||
|
// SaleContracts are created from the payload, and animal links to
|
||||||
|
// unknown gerbils are skipped defensively (only the known father links).
|
||||||
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||||
|
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||||
|
Assert.Equal(ContractId, contract.Id);
|
||||||
|
Assert.Equal(ContactId, contract.ContactId);
|
||||||
|
Assert.Equal(35.0m, contract.Price);
|
||||||
|
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
||||||
|
var link = Assert.Single(contract.Animals);
|
||||||
|
Assert.Equal(FatherId, link.GerbilId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Import:SourcePath", _dir }
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var service = new IngestResolvedService(db, config, null!);
|
||||||
|
await service.RunAsync();
|
||||||
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||||
|
|
||||||
|
// Re-ingest wipes then recreates from the (deterministic) payload —
|
||||||
|
// the contract survives by being part of the payload, no duplicate PK.
|
||||||
|
await service.RunAsync();
|
||||||
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||||
|
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||||
|
Assert.Equal(ContractId, contract.Id);
|
||||||
|
Assert.Single(contract.Animals);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
IReadOnlyList<Guid> GerbilIds,
|
IReadOnlyList<Guid> GerbilIds,
|
||||||
// Download-URL der .docx ("/contracts/{id}/file").
|
// Download-URL der .docx ("/contracts/{id}/file").
|
||||||
string Url);
|
string Url,
|
||||||
|
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
|
||||||
|
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
|
||||||
|
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
|
||||||
|
bool HasFile);
|
||||||
|
|
||||||
public record SaleContractInput(
|
public record SaleContractInput(
|
||||||
Guid ContactId,
|
Guid ContactId,
|
||||||
|
|||||||
@@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||||
|
|
||||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
|
||||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db,
|
||||||
|
IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
{
|
||||||
|
var root = ContractRoot(config, env);
|
||||||
|
return TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||||
.Include(c => c.Animals)
|
.Include(c => c.Animals)
|
||||||
.ToPagedResultAsync(query, ToDto)));
|
.ToPagedResultAsync(query, c => ToDto(c, root)));
|
||||||
|
});
|
||||||
|
|
||||||
// GET /contracts/{id}
|
// GET /contracts/{id}
|
||||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (
|
||||||
|
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
{
|
{
|
||||||
var c = await db.SaleContracts.AsNoTracking()
|
var c = await db.SaleContracts.AsNoTracking()
|
||||||
.Include(x => x.Animals)
|
.Include(x => x.Animals)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id);
|
.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /contracts — der Abgabe-Abschluss.
|
// POST /contracts — der Abgabe-Abschluss.
|
||||||
@@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return cleaned.Trim('-');
|
return cleaned.Trim('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new(
|
||||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||||
$"/contracts/{c.Id}/file");
|
$"/contracts/{c.Id}/file",
|
||||||
|
// Imported contracts have no staged .docx -> HasFile=false (Word
|
||||||
|
// download 404s gracefully). When no root is given (e.g. POST, where
|
||||||
|
// the file was just written) we assume the file exists.
|
||||||
|
HasFile: contractRoot is null
|
||||||
|
|| (!string.IsNullOrEmpty(c.FileName)
|
||||||
|
&& File.Exists(Path.Combine(contractRoot, c.FileName))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,53 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
|
||||||
|
// The table was wiped above, so these are recreated on every ingest
|
||||||
|
// (they survive only by being part of the payload). FK-safe: contacts
|
||||||
|
// and gerbils are already inserted. We skip rows whose buyer contact
|
||||||
|
// is unknown and skip individual animal links to unknown gerbils
|
||||||
|
// (defensive — the resolver should never emit those).
|
||||||
|
// No .docx is copied: the source files live on the network share and
|
||||||
|
// are not staged into Contracts:RootPath, so the Word download 404s
|
||||||
|
// gracefully (the endpoint already returns NotFound) while the PDF is
|
||||||
|
// regenerated from the data. The DTO exposes HasFile=false for these
|
||||||
|
// so the frontend hides the Word button.
|
||||||
|
int contractsAdded = 0;
|
||||||
|
int contractAnimalsSkipped = 0;
|
||||||
|
if (data.SaleContracts.Count > 0)
|
||||||
|
{
|
||||||
|
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||||
|
var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||||
|
var seenContractIds = new HashSet<Guid>();
|
||||||
|
foreach (var sc in data.SaleContracts)
|
||||||
|
{
|
||||||
|
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
|
||||||
|
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
|
||||||
|
|
||||||
|
var animals = new List<SaleContractAnimal>();
|
||||||
|
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
|
||||||
|
{
|
||||||
|
if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; }
|
||||||
|
animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid });
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.SaleContracts.Add(new SaleContract
|
||||||
|
{
|
||||||
|
Id = sc.Id,
|
||||||
|
ContactId = sc.ContactId,
|
||||||
|
Price = sc.Price,
|
||||||
|
HandoverDate = sc.HandoverDate,
|
||||||
|
ContractDate = sc.ContractDate,
|
||||||
|
FileName = sc.FileName,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
Animals = animals,
|
||||||
|
});
|
||||||
|
contractsAdded++;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +294,22 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
public List<Litter> Litters { get; set; } = new();
|
public List<Litter> Litters { get; set; } = new();
|
||||||
public List<Gerbil> Gerbils { get; set; } = new();
|
public List<Gerbil> Gerbils { get; set; } = new();
|
||||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||||
|
public List<ResolvedSaleContract> SaleContracts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine importierte Abgabevertrag-Zeile aus resolved_import.json.
|
||||||
|
/// <c>Animals</c> sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim
|
||||||
|
/// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der
|
||||||
|
/// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt.</summary>
|
||||||
|
public class ResolvedSaleContract
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid ContactId { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public DateOnly HandoverDate { get; set; }
|
||||||
|
public DateOnly ContractDate { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public List<Guid> Animals { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ResolvedPhoto
|
public class ResolvedPhoto
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
fileName: `vertrag-${id}.docx`,
|
fileName: `vertrag-${id}.docx`,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
url: `/contracts/${id}/file`,
|
url: `/contracts/${id}/file`,
|
||||||
|
hasFile: true,
|
||||||
}
|
}
|
||||||
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
||||||
// Tiere auf GivenAway setzen (wie das echte Backend)
|
// Tiere auf GivenAway setzen (wie das echte Backend)
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface MockContract {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
gerbilIds: string[]
|
gerbilIds: string[]
|
||||||
url: string
|
url: string
|
||||||
|
/** Ob die Word-Datei vorliegt. Importierte Verträge: false (kein Word-Button). */
|
||||||
|
hasFile: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
@@ -372,7 +374,21 @@ export function seedDb(): MockDb {
|
|||||||
requests,
|
requests,
|
||||||
aiConfigured: true,
|
aiConfigured: true,
|
||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
contracts: [],
|
// Ein importierter Vertrag (hasFile:false) prüft die Word-Button-Ausblendung.
|
||||||
|
contracts: [
|
||||||
|
{
|
||||||
|
id: 'contract-import-1',
|
||||||
|
contactId: 'con-meier',
|
||||||
|
price: 30,
|
||||||
|
handoverDate: '2023-09-29',
|
||||||
|
contractDate: '2023-09-29',
|
||||||
|
fileName: 'Zucht der kleinen Chaoten _ (Krümel) - Zoohandlung Meier_.docx',
|
||||||
|
createdAt: '2023-09-29T00:00:00Z',
|
||||||
|
gerbilIds: ['kruemel'],
|
||||||
|
url: '/contracts/contract-import-1/file',
|
||||||
|
hasFile: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
namesConfigured: true,
|
namesConfigured: true,
|
||||||
feedback: [],
|
feedback: [],
|
||||||
|
|||||||
33
gerbil-manager-web/e2e/vertraege.spec.ts
Normal file
33
gerbil-manager-web/e2e/vertraege.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* FEAT-Contracts: Verträge-Liste (/vertraege) zeigt importierte Abgabeverträge.
|
||||||
|
*
|
||||||
|
* Der Mock-Seed enthält EINEN importierten Vertrag (hasFile:false). Wir prüfen,
|
||||||
|
* dass er gerendert wird (Abnehmer, Preis, Datum, Tieranzahl) und dass der
|
||||||
|
* Word-Download-Button für ihn ausgeblendet ist (kein .docx), während der
|
||||||
|
* PDF-Button (aus den Daten neu erzeugt) sichtbar bleibt.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.vertraege
|
||||||
|
|
||||||
|
test.describe('Verträge-Liste', () => {
|
||||||
|
test('importierter Vertrag wird gelistet; Word-Button ausgeblendet, PDF bleibt', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.contracts)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
const card = page.locator('.vertrag-card').filter({ hasText: 'Zoohandlung Meier' })
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
// Tieranzahl + Preis + Übergabedatum erscheinen in der Meta-Zeile.
|
||||||
|
await expect(card).toContainText(t.animalsCount(1))
|
||||||
|
await expect(card).toContainText('30,00 €')
|
||||||
|
await expect(card).toContainText('29.09.2023')
|
||||||
|
|
||||||
|
// PDF-Button vorhanden, Word-Button NICHT (importiert, keine Datei).
|
||||||
|
await expect(card.getByRole('link', { name: t.downloadPdf })).toBeVisible()
|
||||||
|
await expect(card.getByRole('link', { name: t.download, exact: true })).toHaveCount(0)
|
||||||
|
await expect(card.getByText(t.noWordFileShort)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,9 @@ export interface SaleContract {
|
|||||||
gerbilIds: string[]
|
gerbilIds: string[]
|
||||||
/** API-relativer Download-Pfad ("/contracts/{id}/file"). */
|
/** API-relativer Download-Pfad ("/contracts/{id}/file"). */
|
||||||
url: string
|
url: string
|
||||||
|
/** Ob die Word-Datei im Vertrags-Dateiroot liegt. Importierte Verträge haben
|
||||||
|
* keine Datei (Word-Download 404) — das PDF wird trotzdem neu erzeugt. */
|
||||||
|
hasFile: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSaleContract {
|
export interface CreateSaleContract {
|
||||||
|
|||||||
@@ -84,9 +84,15 @@ export default function VertraegeListPage() {
|
|||||||
<a className="btn btn--primary" href={contractPdfUrl(c)}>
|
<a className="btn btn--primary" href={contractPdfUrl(c)}>
|
||||||
{t.downloadPdf}
|
{t.downloadPdf}
|
||||||
</a>
|
</a>
|
||||||
<a className="btn" href={contractFileUrl(c)}>
|
{c.hasFile ? (
|
||||||
{t.download}
|
<a className="btn" href={contractFileUrl(c)}>
|
||||||
</a>
|
{t.download}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="muted" title={t.noWordFile}>
|
||||||
|
{t.noWordFileShort}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--danger"
|
className="btn btn--danger"
|
||||||
|
|||||||
@@ -472,6 +472,9 @@ export const de = {
|
|||||||
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
|
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
|
||||||
download: 'Word',
|
download: 'Word',
|
||||||
downloadPdf: 'PDF',
|
downloadPdf: 'PDF',
|
||||||
|
noWordFileShort: 'Keine Word-Datei',
|
||||||
|
noWordFile:
|
||||||
|
'Für importierte Verträge liegt keine Word-Datei vor — das PDF wird aus den Daten erzeugt.',
|
||||||
delete: 'Löschen',
|
delete: 'Löschen',
|
||||||
confirmDelete:
|
confirmDelete:
|
||||||
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
||||||
|
|||||||
@@ -957,9 +957,28 @@ def _append_history(prov_json, line):
|
|||||||
return json.dumps(prov, ensure_ascii=False)
|
return json.dumps(prov, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_price(raw):
|
||||||
|
"""Parse a contract price string ('27,50' / '30.00' / '') → float (0.0 if empty).
|
||||||
|
|
||||||
|
extract_contracts.py emits German-formatted numbers ('27,50'); accept both
|
||||||
|
comma and dot decimal separators. Unparseable/empty → 0.0 (a price-less
|
||||||
|
contract is still a valid contract record)."""
|
||||||
|
if raw is None:
|
||||||
|
return 0.0
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return 0.0
|
||||||
|
s = s.replace(".", "").replace(",", ".") if ("," in s) else s
|
||||||
|
try:
|
||||||
|
return round(float(s), 2)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||||
contact_id_map):
|
contact_id_map):
|
||||||
"""Conservatively fold Abgabevertrag data into the resolved gerbils.
|
"""Conservatively fold Abgabevertrag data into the resolved gerbils AND emit
|
||||||
|
one SaleContract record per contract with a resolvable buyer.
|
||||||
|
|
||||||
For every parsed contract we (a) ensure the buyer exists as a (receiver)
|
For every parsed contract we (a) ensure the buyer exists as a (receiver)
|
||||||
contact, reusing the existing contact dedup/normalisation, and (b) try to
|
contact, reusing the existing contact dedup/normalisation, and (b) try to
|
||||||
@@ -969,18 +988,37 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
|||||||
and add a provenance history line. Ambiguous or absent matches are logged,
|
and add a provenance history line. Ambiguous or absent matches are logged,
|
||||||
never guessed.
|
never guessed.
|
||||||
|
|
||||||
Returns a stats dict. Mutates resolved_gerbils + contact_by_norm_name in
|
In addition we build a `sale_contracts` list (one record per contract whose
|
||||||
place. New buyer contacts are appended via contact_by_norm_name so the later
|
buyer resolves to a contact). Each record carries a deterministic Id (from
|
||||||
IsReceiver-flag pass picks them up automatically.
|
the source filename, so re-ingest is idempotent), the resolved buyer
|
||||||
|
ContactId, the parsed Price, the parsed dates and the gerbil ids that
|
||||||
|
matched for that contract. Contracts with NO date at all are skipped from
|
||||||
|
record creation (the SaleContract.HandoverDate/ContractDate columns are
|
||||||
|
non-nullable DateOnly) and counted in stats["dateless_skipped"]; contracts
|
||||||
|
whose buyer cannot be resolved are counted in stats["no_buyer_skipped"].
|
||||||
|
|
||||||
|
Returns (stats, sale_contracts). Mutates resolved_gerbils +
|
||||||
|
contact_by_norm_name in place. New buyer contacts are appended via
|
||||||
|
contact_by_norm_name so the later IsReceiver-flag pass picks them up
|
||||||
|
automatically.
|
||||||
"""
|
"""
|
||||||
stats = {
|
stats = {
|
||||||
"contracts": len(contracts), "buyers_created": 0, "buyers_existing": 0,
|
"contracts": len(contracts), "buyers_created": 0, "buyers_existing": 0,
|
||||||
"matched": 0, "ambiguous_skipped": 0, "no_match_skipped": 0,
|
"matched": 0, "ambiguous_skipped": 0, "no_match_skipped": 0,
|
||||||
"receiver_set": 0, "gohome_set": 0, "status_givenaway": 0,
|
"receiver_set": 0, "gohome_set": 0, "status_givenaway": 0,
|
||||||
"conflicts": 0,
|
"conflicts": 0,
|
||||||
|
"records_created": 0, "no_buyer_skipped": 0, "dateless_skipped": 0,
|
||||||
|
"records_with_animal": 0, "records_with_date": 0,
|
||||||
}
|
}
|
||||||
|
sale_contracts = []
|
||||||
|
# The same contract filename can appear more than once in contracts.json
|
||||||
|
# (the .docx is filed in several subfolders of the share). The record Id is
|
||||||
|
# derived from the filename, so we must collapse those into ONE record per
|
||||||
|
# Id (a duplicate PK would break ingest). Keyed by Id; animal lists are
|
||||||
|
# merged and a missing date is back-filled from the duplicate.
|
||||||
|
records_by_id = {}
|
||||||
if not contracts:
|
if not contracts:
|
||||||
return stats
|
return stats, sale_contracts
|
||||||
|
|
||||||
# Index breeder-owned gerbils by dedup name key. Contracts only ever sell
|
# Index breeder-owned gerbils by dedup name key. Contracts only ever sell
|
||||||
# animals the breeder bred, so restrict candidates to her own stock to avoid
|
# animals the breeder bred, so restrict candidates to her own stock to avoid
|
||||||
@@ -1035,8 +1073,10 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
|||||||
|
|
||||||
# --- match each animal call-name to a resolved gerbil ---
|
# --- match each animal call-name to a resolved gerbil ---
|
||||||
handover = parse_date(c.get("handoverDate"))
|
handover = parse_date(c.get("handoverDate"))
|
||||||
|
contract_date = parse_date(c.get("contractDate"))
|
||||||
c_year = year_of(parse_date(c.get("dob"))) if c.get("dob") else None
|
c_year = year_of(parse_date(c.get("dob"))) if c.get("dob") else None
|
||||||
c_color = (c.get("color") or "").strip().lower()
|
c_color = (c.get("color") or "").strip().lower()
|
||||||
|
matched_gerbil_ids = [] # gerbils this contract resolved to (for the record)
|
||||||
|
|
||||||
for call in (c.get("animals") or []):
|
for call in (c.get("animals") or []):
|
||||||
key = get_dedup_name_key(get_call_name(call))
|
key = get_dedup_name_key(get_call_name(call))
|
||||||
@@ -1071,6 +1111,8 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
stats["matched"] += 1
|
stats["matched"] += 1
|
||||||
|
if chosen.get("Id") and chosen["Id"] not in matched_gerbil_ids:
|
||||||
|
matched_gerbil_ids.append(chosen["Id"])
|
||||||
|
|
||||||
# --- set receiver, only if not already set differently ---
|
# --- set receiver, only if not already set differently ---
|
||||||
if buyer_global_id:
|
if buyer_global_id:
|
||||||
@@ -1103,7 +1145,48 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
|||||||
chosen["Status"] = "GivenAway"
|
chosen["Status"] = "GivenAway"
|
||||||
stats["status_givenaway"] += 1
|
stats["status_givenaway"] += 1
|
||||||
|
|
||||||
return stats
|
# --- emit a SaleContract record for this contract ---------------------
|
||||||
|
# Only contracts with a resolvable buyer become records (the row needs a
|
||||||
|
# ContactId). A record with zero matched animals is still kept — better
|
||||||
|
# to show the contract than to drop it.
|
||||||
|
if not buyer_global_id:
|
||||||
|
stats["no_buyer_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# HandoverDate/ContractDate are non-nullable DateOnly in the DB. Fall
|
||||||
|
# back from one to the other; if BOTH are missing, skip the record
|
||||||
|
# (we do not invent dates) and count it.
|
||||||
|
h = handover or contract_date
|
||||||
|
cd = contract_date or handover
|
||||||
|
if not h: # implies cd is also None
|
||||||
|
stats["dateless_skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
rec_id = generate_guid(f"contract-{fname}")
|
||||||
|
existing = records_by_id.get(rec_id)
|
||||||
|
if existing is None:
|
||||||
|
records_by_id[rec_id] = {
|
||||||
|
"Id": rec_id,
|
||||||
|
"ContactId": buyer_global_id,
|
||||||
|
"Price": _parse_price(c.get("price")),
|
||||||
|
"HandoverDate": h,
|
||||||
|
"ContractDate": cd,
|
||||||
|
"FileName": fname,
|
||||||
|
"Animals": list(matched_gerbil_ids),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Same filename seen again — merge animal matches; back-fill price.
|
||||||
|
for gid in matched_gerbil_ids:
|
||||||
|
if gid not in existing["Animals"]:
|
||||||
|
existing["Animals"].append(gid)
|
||||||
|
if not existing["Price"]:
|
||||||
|
existing["Price"] = _parse_price(c.get("price"))
|
||||||
|
|
||||||
|
sale_contracts = list(records_by_id.values())
|
||||||
|
stats["records_created"] = len(sale_contracts)
|
||||||
|
stats["records_with_animal"] = sum(1 for r in sale_contracts if r["Animals"])
|
||||||
|
stats["records_with_date"] = sum(1 for r in sale_contracts if r["HandoverDate"])
|
||||||
|
return stats, sale_contracts
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -2863,7 +2946,7 @@ def main():
|
|||||||
# Status=GivenAway setzen (nur falls noch nicht gesetzt). Provenance-
|
# Status=GivenAway setzen (nur falls noch nicht gesetzt). Provenance-
|
||||||
# Historie wird ergänzt. Neue Käuferkontakte landen in contact_by_norm_name
|
# Historie wird ergänzt. Neue Käuferkontakte landen in contact_by_norm_name
|
||||||
# und werden danach automatisch als IsReceiver markiert.
|
# und werden danach automatisch als IsReceiver markiert.
|
||||||
cstats = enrich_from_contracts(
|
cstats, sale_contracts = enrich_from_contracts(
|
||||||
contracts, resolved_gerbils, contact_by_norm_name, contact_id_map)
|
contracts, resolved_gerbils, contact_by_norm_name, contact_id_map)
|
||||||
if contracts:
|
if contracts:
|
||||||
print("Abgabeverträge: "
|
print("Abgabeverträge: "
|
||||||
@@ -2877,6 +2960,12 @@ def main():
|
|||||||
f"GoHomeDate={cstats['gohome_set']}, "
|
f"GoHomeDate={cstats['gohome_set']}, "
|
||||||
f"Status=GivenAway={cstats['status_givenaway']}, "
|
f"Status=GivenAway={cstats['status_givenaway']}, "
|
||||||
f"Konflikte={cstats['conflicts']}.")
|
f"Konflikte={cstats['conflicts']}.")
|
||||||
|
print(" Vertragszeilen: "
|
||||||
|
f"{cstats['records_created']} angelegt "
|
||||||
|
f"({cstats['records_with_animal']} mit Tier, "
|
||||||
|
f"{cstats['records_with_date']} mit Originaldatum), "
|
||||||
|
f"{cstats['no_buyer_skipped']} ohne Käufer übersprungen, "
|
||||||
|
f"{cstats['dateless_skipped']} ohne Datum übersprungen.")
|
||||||
# Re-materialise contacts so freshly created buyer contacts are exported.
|
# Re-materialise contacts so freshly created buyer contacts are exported.
|
||||||
resolved_contacts = list(contact_by_norm_name.values())
|
resolved_contacts = list(contact_by_norm_name.values())
|
||||||
|
|
||||||
@@ -2989,17 +3078,19 @@ def main():
|
|||||||
"contacts": resolved_contacts,
|
"contacts": resolved_contacts,
|
||||||
"litters": resolved_litters,
|
"litters": resolved_litters,
|
||||||
"gerbils": resolved_gerbils,
|
"gerbils": resolved_gerbils,
|
||||||
"gerbilPhotos": resolved_photos
|
"gerbilPhotos": resolved_photos,
|
||||||
|
"saleContracts": sale_contracts,
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
print(f"Successfully wrote database-ready import file to: {OUTPUT_FILE}")
|
print(f"Successfully wrote database-ready import file to: {OUTPUT_FILE}")
|
||||||
print(f" Contacts: {len(payload['contacts'])}")
|
print(f" Contacts: {len(payload['contacts'])}")
|
||||||
print(f" Litters: {len(payload['litters'])}")
|
print(f" Litters: {len(payload['litters'])}")
|
||||||
print(f" Gerbils: {len(payload['gerbils'])}")
|
print(f" Gerbils: {len(payload['gerbils'])}")
|
||||||
print(f" Photos: {len(payload['gerbilPhotos'])}")
|
print(f" Photos: {len(payload['gerbilPhotos'])}")
|
||||||
|
print(f" SaleContracts: {len(payload['saleContracts'])}")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -345,6 +345,75 @@ check("history: discard lines carry the warning marker",
|
|||||||
all(s.startswith(m.DISCARD_MARK) for s in h_disc if "verworfen" in s))
|
all(s.startswith(m.DISCARD_MARK) for s in h_disc if "verworfen" in s))
|
||||||
|
|
||||||
|
|
||||||
|
# ── enrich_from_contracts: SaleContract record emission ───────────────────────
|
||||||
|
# A contract whose buyer resolves to a contact and whose animal call-name matches
|
||||||
|
# a breeder-owned gerbil must yield a SaleContract record carrying the buyer
|
||||||
|
# ContactId, a deterministic Id, the parsed dates and the matched gerbil id.
|
||||||
|
def _balu():
|
||||||
|
# A breeder-owned ("Chaoten") gerbil whose call-name is "Balu".
|
||||||
|
return {
|
||||||
|
"Id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"Name": "Balu von den kleinen Chaoten",
|
||||||
|
"Gender": "male", "DateOfBirth": "2022-05-01",
|
||||||
|
"OriginBreeder": "Zucht der kleinen Chaoten",
|
||||||
|
"Status": "Active", "ColorVarietyId": None,
|
||||||
|
"Provenance": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
_g = _balu()
|
||||||
|
_resolved = [_g]
|
||||||
|
_contacts_by_norm = {}
|
||||||
|
_contracts = [{
|
||||||
|
"sourceFile": "Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx",
|
||||||
|
"buyer": "Max Muster", "animals": ["Balu"], "color": "schwarz",
|
||||||
|
"gender": "Male", "dob": "2022-05-01",
|
||||||
|
"handoverDate": "2022-07-01", "contractDate": "2022-07-01", "price": "30,00",
|
||||||
|
}]
|
||||||
|
_stats, _sale = m.enrich_from_contracts(_contracts, _resolved, _contacts_by_norm, {})
|
||||||
|
check("contracts: exactly one SaleContract record emitted", len(_sale) == 1)
|
||||||
|
_rec = _sale[0] if _sale else {}
|
||||||
|
check("contracts: record Id is deterministic from filename",
|
||||||
|
_rec.get("Id") == m.generate_guid(
|
||||||
|
"contract-Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx"))
|
||||||
|
check("contracts: record ContactId is the resolved buyer contact",
|
||||||
|
_rec.get("ContactId") and
|
||||||
|
_rec["ContactId"] == _contacts_by_norm.get(m.normalize_name("Max Muster"), {}).get("Id"))
|
||||||
|
check("contracts: record lists the matched gerbil",
|
||||||
|
_rec.get("Animals") == [_g["Id"]])
|
||||||
|
check("contracts: price parsed as float", _rec.get("Price") == 30.0)
|
||||||
|
check("contracts: dates carried through",
|
||||||
|
_rec.get("HandoverDate") == "2022-07-01" and _rec.get("ContractDate") == "2022-07-01")
|
||||||
|
check("contracts: stats count the created record", _stats.get("records_created") == 1)
|
||||||
|
|
||||||
|
# A dateless contract is skipped from record creation (non-nullable DateOnly) but
|
||||||
|
# still counted, and the buyer contact is still created.
|
||||||
|
_c2 = [{
|
||||||
|
"sourceFile": "Zucht der kleinen Chaoten _ (Nala) - Erika Muster_.docx",
|
||||||
|
"buyer": "Erika Muster", "animals": ["Nala"], "color": "",
|
||||||
|
"gender": "", "dob": "", "handoverDate": "", "contractDate": "", "price": "",
|
||||||
|
}]
|
||||||
|
_stats2, _sale2 = m.enrich_from_contracts(_c2, [], {}, {})
|
||||||
|
check("contracts: dateless contract skipped from records", len(_sale2) == 0)
|
||||||
|
check("contracts: dateless contract counted", _stats2.get("dateless_skipped") == 1)
|
||||||
|
|
||||||
|
# Price-only / no-date fallback: contract with only a contractDate gets it copied
|
||||||
|
# into HandoverDate too (and vice versa), and an animal-less contract still
|
||||||
|
# becomes a record (better to show it than drop it).
|
||||||
|
_c3 = [{
|
||||||
|
"sourceFile": "Zucht der kleinen Chaoten _ (Unbekannt) - Tom Muster_.docx",
|
||||||
|
"buyer": "Tom Muster", "animals": ["Unbekannt"], "color": "",
|
||||||
|
"gender": "", "dob": "", "handoverDate": "", "contractDate": "2023-01-15",
|
||||||
|
"price": "",
|
||||||
|
}]
|
||||||
|
_stats3, _sale3 = m.enrich_from_contracts(_c3, [], {}, {})
|
||||||
|
check("contracts: animal-less contract still becomes a record", len(_sale3) == 1)
|
||||||
|
check("contracts: missing handover falls back to contract date",
|
||||||
|
_sale3 and _sale3[0]["HandoverDate"] == "2023-01-15"
|
||||||
|
and _sale3[0]["ContractDate"] == "2023-01-15")
|
||||||
|
check("contracts: animal-less record has empty Animals list",
|
||||||
|
_sale3 and _sale3[0]["Animals"] == [])
|
||||||
|
|
||||||
|
|
||||||
if check.failed:
|
if check.failed:
|
||||||
print(f"\n{check.failed} test(s) FAILED")
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user