Dieser Kurs arbeitet an einem Programm, das Sie selbst aufbauen. Es gehört zu der erfundenen Firma Brandt Haustechnik (Heizung, Sanitär, Wartung) und heißt Auftragsbuch. So muss niemand etwas herunterladen, und Sie kennen jede Stelle, an der später etwas bricht, weil Sie sie selbst geschrieben haben. Alle Personen, Firmen und Adressen sind erfunden.
Das Auftragsbuch ist absichtlich ein typisches gewachsenes Programm: ein Windows-Forms-Projekt auf .NET Framework 4.8, Daten in einem
DataSet, Zugriff mit ADO.NET gegen SQL Server, Einstellungen in App.config, Anmeldung über Windows, Rechnungsdruck mit
PrintDocument, ein Export in eine Datei auf dem Netzlaufwerk und ein Browser-Steuerelement für die Kundenkarte. Es ist klein genug,
um es in einer Lektion zu schreiben, und es enthält alles, was eine Modernisierung später berührt. Gut gebaut ist es nicht, und das
ist Absicht: Es ist gebaut, wie solche Programme eben entstehen.
Windows Forms lernen Sie hier nicht. Wer es noch nicht kennt, liest den Code trotzdem: Er ist kurz, und die Kommentare sagen, was passiert. Der Kurs erklärt Formulare nur, soweit eine Migration sie berührt.
Hausordnung für alle Übungen
- Das Programm liegt in Ihrem eigenen Repository
Brandt.Auftragsbuch. Jeder Schritt dieser Lektion ist ein eigener Commit mit einem Titel nach Conventional Commits, etwafeat(daten): Datenbankskript und Anlegeskript. - Keine Geheimnisse in Git. Die Verbindungszeichenfolge dieser Lektion nutzt die Windows-Anmeldung und enthält kein Kennwort; das bleibt so (Lektion M5.3 räumt auch den Rest auf).
- Vor jedem späteren Schritt gibt es ein Sicherheitsnetz: Tests oder eine Kopie der Datenbank (Lektion M2.1).
- Jede Azure-Übung endet mit dem Abbau. Diese Lektion läuft vollständig lokal.
- Es zählt der Exit-Code, nicht die Meldung „fertig“.
Was Sie brauchen
- Windows und Visual Studio mit den Komponenten für die .NET-Desktopentwicklung. Sie bauen ein Projekt für .NET Framework 4.8.
- SQL Server LocalDB, die kleine Entwicklerfassung von SQL Server. Microsofts Dokumentation nennt für die automatische Instanz die
Verbindungszeichenfolge
Server=(localdb)\MSSQLLocalDB;Integrated Security=true. Ein SQL Server im Container oder auf einem anderen Rechner geht auch; dann ändern SieServer=inApp.config. sqlcmdfür das Anlegeskript.
So sieht der Ordner am Ende aus:
Brandt.Auftragsbuch/
Brandt.Auftragsbuch.sln
datenbank.sql
scripts/
Datenbank-anlegen.ps1
Brandt.Auftragsbuch/
Brandt.Auftragsbuch.csproj
packages.config
App.config
Program.cs
Einstellungen.cs
Berechtigung.cs
AuftragsDaten.cs
HauptForm.cs
KundenForm.cs
WartungsForm.cs
Rechnungsdruck.cs
Exporte.cs
Properties/AssemblyInfo.cs
Legen Sie die Dateien in dieser Reihenfolge an. Im Code stehen Kommentare der Form BRUCH B<Nr>: Sie markieren die Stellen, die in den
nächsten Modulen bearbeitet werden. Am Ende der Lektion finden Sie sie in einer Tabelle.
Schritt 1: die Datenbank
Drei Tabellen: Kunden, Auftraege und Wartungsplan. Dazu eine gespeicherte Prozedur, die die fälligen Wartungen liefert, und ein
Trigger, der den Wartungsplan weiterschiebt, sobald ein Wartungsauftrag erledigt ist. Beides sind Regeln, die in der Datenbank leben
und in keinem Formular stehen; das merken Sie sich für die Inventur. Die Beispieldaten arbeiten mit relativen Datumsangaben
(SYSDATETIME()), damit sie immer „frisch“ sind.
IF DB_ID(N'Auftragsbuch') IS NULL CREATE DATABASE Auftragsbuch;
GO
USE Auftragsbuch;
GO
IF OBJECT_ID(N'dbo.Kunden') IS NULL
CREATE TABLE dbo.Kunden (
KundeId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Kunden PRIMARY KEY,
Name nvarchar(100) NOT NULL,
Strasse nvarchar(100) NOT NULL,
Plz nvarchar(10) NOT NULL,
Ort nvarchar(60) NOT NULL,
Telefon nvarchar(30) NULL,
Notiz nvarchar(400) NULL
);
GO
IF OBJECT_ID(N'dbo.Wartungsplan') IS NULL
CREATE TABLE dbo.Wartungsplan (
WartungId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Wartungsplan PRIMARY KEY,
KundeId int NOT NULL CONSTRAINT FK_Wartungsplan_Kunden REFERENCES dbo.Kunden (KundeId),
Anlage nvarchar(100) NOT NULL,
IntervallMonate int NOT NULL CONSTRAINT CK_Wartungsplan_Intervall CHECK (IntervallMonate BETWEEN 1 AND 60),
LetzteWartung date NOT NULL
);
GO
IF OBJECT_ID(N'dbo.Auftraege') IS NULL
CREATE TABLE dbo.Auftraege (
AuftragId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Auftraege PRIMARY KEY,
KundeId int NOT NULL CONSTRAINT FK_Auftraege_Kunden REFERENCES dbo.Kunden (KundeId),
WartungId int NULL CONSTRAINT FK_Auftraege_Wartungsplan REFERENCES dbo.Wartungsplan (WartungId),
Titel nvarchar(200) NOT NULL,
Art nvarchar(20) NOT NULL CONSTRAINT CK_Auftraege_Art CHECK (Art IN (N'Heizung', N'Sanitaet', N'Wartung')),
Status nvarchar(20) NOT NULL CONSTRAINT CK_Auftraege_Status CHECK (Status IN (N'Neu', N'Geplant', N'Erledigt', N'Abgerechnet')),
Termin date NULL,
Stunden decimal(6,2) NOT NULL CONSTRAINT DF_Auftraege_Stunden DEFAULT (0),
Material decimal(10,2) NOT NULL CONSTRAINT DF_Auftraege_Material DEFAULT (0),
Rechnungsnummer nvarchar(20) NULL
);
GO
-- Wird ein Wartungsauftrag erledigt, rückt der Wartungsplan weiter. Diese Regel steht nur hier.
CREATE OR ALTER TRIGGER dbo.trg_Auftraege_WartungErledigt ON dbo.Auftraege AFTER UPDATE AS
BEGIN
SET NOCOUNT ON;
UPDATE w SET LetzteWartung = i.Termin
FROM dbo.Wartungsplan AS w
JOIN inserted AS i ON i.WartungId = w.WartungId
JOIN deleted AS d ON d.AuftragId = i.AuftragId
WHERE i.Status = N'Erledigt' AND d.Status <> N'Erledigt' AND i.Termin IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WartungFaellig @Bis date AS
BEGIN
SET NOCOUNT ON;
SELECT w.WartungId, w.KundeId, k.Name AS Kunde, w.Anlage, w.IntervallMonate, w.LetzteWartung,
DATEADD(MONTH, w.IntervallMonate, w.LetzteWartung) AS Naechste
FROM dbo.Wartungsplan AS w
JOIN dbo.Kunden AS k ON k.KundeId = w.KundeId
WHERE DATEADD(MONTH, w.IntervallMonate, w.LetzteWartung) <= @Bis
ORDER BY Naechste;
END;
GO
IF NOT EXISTS (SELECT 1 FROM dbo.Kunden)
BEGIN
INSERT dbo.Kunden (Name, Strasse, Plz, Ort, Telefon, Notiz) VALUES
(N'Bäckerei Sommer', N'Lindenweg 4', N'31999', N'Neuenfeld', N'05100 555-0101', N'Hinterhof, Schlüssel beim Chef'),
(N'Hausverwaltung Lindemann', N'Marktplatz 2', N'31999', N'Neuenfeld', N'05100 555-0102', NULL),
(N'Frau Petersen', N'Birkenallee 17', N'31999', N'Neuenfeld', N'05100 555-0103', N'Hund im Garten'),
(N'Autohaus Reuter', N'Industriestraße 9', N'31998', N'Ahlbach', N'05101 555-0104', NULL),
(N'Kita Sonnenschein', N'Schulstraße 3', N'31998', N'Ahlbach', N'05101 555-0105', N'Vormittags anrufen');
INSERT dbo.Wartungsplan (KundeId, Anlage, IntervallMonate, LetzteWartung) VALUES
(1, N'Gasheizung Keller', 12, DATEADD(MONTH, -12, CAST(SYSDATETIME() AS date))),
(2, N'Heizzentrale Marktplatz 2', 6, DATEADD(MONTH, -5, CAST(SYSDATETIME() AS date))),
(4, N'Ölkessel Werkstatt', 12, DATEADD(MONTH, -3, CAST(SYSDATETIME() AS date))),
(5, N'Wärmepumpe', 12, DATEADD(MONTH, -11, CAST(SYSDATETIME() AS date)));
INSERT dbo.Auftraege (KundeId, WartungId, Titel, Art, Status, Termin, Stunden, Material, Rechnungsnummer) VALUES
(1, NULL, N'Heizkörper im Verkaufsraum entlüften', N'Heizung', N'Abgerechnet', DATEADD(DAY, -20, CAST(SYSDATETIME() AS date)), 1.50, 12.00, N'R-SEED-00001'),
(3, NULL, N'Wasserhahn in der Küche tauschen', N'Sanitaet', N'Erledigt', DATEADD(DAY, -3, CAST(SYSDATETIME() AS date)), 2.25, 48.90, NULL),
(4, NULL, N'Heizungspumpe ersetzen', N'Heizung', N'Geplant', DATEADD(DAY, 6, CAST(SYSDATETIME() AS date)), 0, 0, NULL),
(2, NULL, N'Badsanierung Wohnung 3, Angebot', N'Sanitaet', N'Neu', NULL, 0, 0, NULL),
(5, 4, N'Wartung: Wärmepumpe', N'Wartung', N'Geplant', DATEADD(DAY, 30, CAST(SYSDATETIME() AS date)), 0, 0, NULL);
END;
GO
Das Skript ist wiederholbar: Jede Tabelle wird nur angelegt, wenn es sie nicht gibt, Trigger und Prozedur mit CREATE OR ALTER, die
Beispieldaten nur in eine leere Tabelle. Ein PowerShell-Skript führt es aus, mit -WhatIf wie alle Skripte dieses Kurses. Die
Zeichenkodierung 65001 ist UTF-8, damit die Umlaute in den Beispieldaten ankommen.
<#
.SYNOPSIS
Legt die Datenbank des Auftragsbuchs an (Tabellen, Trigger, Prozedur, Beispieldaten). Ein zweiter Lauf ändert nichts.
.EXAMPLE
./scripts/Datenbank-anlegen.ps1 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Server = '(localdb)\MSSQLLocalDB',
[string] $Skript = (Join-Path $PSScriptRoot '..' 'datenbank.sql')
)
$ErrorActionPreference = 'Stop'
$Skript = (Resolve-Path -LiteralPath $Skript).Path
if ($PSCmdlet.ShouldProcess("$Server (Auftragsbuch)", 'Datenbank anlegen')) {
sqlcmd -S $Server -E -b -f 65001 -i $Skript
if ($LASTEXITCODE -ne 0) {
throw "sqlcmd endete mit dem Exit-Code $LASTEXITCODE."
}
Write-Output "Datenbank Auftragsbuch auf $Server ist bereit."
}
./scripts/Datenbank-anlegen.ps1 -WhatIf
./scripts/Datenbank-anlegen.ps1
Prüfen Sie das Ergebnis mit einer Abfrage:
sqlcmd -S "(localdb)\MSSQLLocalDB" -E -d Auftragsbuch -Q "SELECT (SELECT COUNT(*) FROM dbo.Kunden) AS Kunden, (SELECT COUNT(*) FROM dbo.Auftraege) AS Auftraege, (SELECT COUNT(*) FROM dbo.Wartungsplan) AS Wartungsplan"
Kunden Auftraege Wartungsplan
----------- ----------- ------------
5 5 4
(1 rows affected)
Schritt 2: das Projekt
Das Projekt hat das klassische Format: Jede Quelldatei steht einzeln in der Projektdatei, die Zielversion heißt
TargetFrameworkVersion v4.8, und die Pakete stehen in einer eigenen Datei packages.config. So sahen Projekte vor dem
SDK-Format aus, und genau das müssen Sie später umstellen (Bruchstelle B1). Das Paket Newtonsoft.Json schreibt später den Plan für
die Monteure. LangVersion steht auf 7.3, weil Programme dieser Art meist in dem Stil geschrieben sind.
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- BRUCH B1: klassisches Projektformat, jede Datei einzeln aufgeführt, Pakete über packages.config -->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{6F1D2A4C-3B7E-4C55-9A0E-5D2B8C1F7A10}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>Brandt.Auftragsbuch</RootNamespace>
<AssemblyName>Brandt.Auftragsbuch</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<LangVersion>7.3</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Core" />
<Reference Include="System.Data" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Compile Include="AuftragsDaten.cs" />
<Compile Include="Berechtigung.cs" />
<Compile Include="Einstellungen.cs" />
<Compile Include="Exporte.cs" />
<Compile Include="HauptForm.cs" />
<Compile Include="KundenForm.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Rechnungsdruck.cs" />
<Compile Include="WartungsForm.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>
Die Einstellungen stehen in App.config: die Verbindung, der Stundensatz, die Anfahrtspauschale, der Mehrwertsteuersatz, der Ordner
auf dem Netzlaufwerk und die Windows-Gruppe der Disposition. Das sind erfundene Werte. \\brandt-srv01\Buchhaltung\Auftragsbuch
gibt es auf Ihrem Rechner nicht; für die Übung ändern Sie den Wert gleich.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<connectionStrings>
<add name="Auftragsbuch"
connectionString="Server=(localdb)\MSSQLLocalDB;Database=Auftragsbuch;Integrated Security=SSPI"
providerName="System.Data.SqlClient" />
</connectionStrings>
<appSettings>
<add key="Stundensatz" value="68.00" />
<add key="Anfahrtspauschale" value="29.50" />
<add key="Mwst" value="0.19" />
<add key="ExportOrdner" value="\\brandt-srv01\Buchhaltung\Auftragsbuch" />
<add key="GruppeDisposition" value="BRANDT\Disposition" />
</appSettings>
</configuration>
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("Brandt Auftragsbuch")]
[assembly: AssemblyCompany("Brandt Haustechnik")]
[assembly: AssemblyProduct("Auftragsbuch")]
[assembly: ComVisible(false)]
[assembly: AssemblyVersion("1.8.3.0")]
[assembly: AssemblyFileVersion("1.8.3.0")]
Dann die Projektmappe. Seit .NET 10 erzeugt dotnet new sln eine Datei im neuen Format .slnx; die klassische .sln-Datei bekommen
Sie mit --format sln. Ein Projekt im klassischen Format tragen Sie mit dotnet sln add ein:
dotnet new sln --format sln -n Brandt.Auftragsbuch
dotnet sln add Brandt.Auftragsbuch\Brandt.Auftragsbuch.csproj
Die Vorlage "Projektmappendatei" wurde erfolgreich erstellt.
Das Projekt "Brandt.Auftragsbuch\Brandt.Auftragsbuch.csproj" wurde der Projektmappe hinzugefügt.
Schritt 3: Start, Einstellungen, Rechte
Program startet das Programm: Es lädt die Daten einmal in ein statisches Objekt (Bruchstelle B11: ein globaler Zustand für alle
Formulare) und meldet unerwartete Fehler an das Ereignisprotokoll von Windows (B12). Ohne erreichbare Datenbank erscheint eine Meldung,
das Programm beendet sich.
using System;
using System.Data.SqlClient;
using System.Diagnostics;
using System.Windows.Forms;
namespace Brandt.Auftragsbuch
{
internal static class Program
{
// BRUCH B11: Ein einziger, globaler Zustand für das ganze Programm.
public static AuftragsDaten Daten { get; private set; }
[STAThread]
private static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.ThreadException += (s, e) => Melden(e.Exception);
try
{
Daten = new AuftragsDaten(Einstellungen.Verbindung);
Daten.Laden();
}
catch (SqlException ex)
{
MessageBox.Show("Die Datenbank ist nicht erreichbar: " + ex.Message, "Auftragsbuch", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Application.Run(new HauptForm());
}
// BRUCH B12: Fehler gehen in das Ereignisprotokoll von Windows.
private static void Melden(Exception fehler)
{
try
{
EventLog.WriteEntry("Application", "Auftragsbuch: " + fehler, EventLogEntryType.Error);
}
catch (Exception)
{
// Ohne Recht auf das Protokoll bleibt nur die Meldung.
}
MessageBox.Show("Unerwarteter Fehler: " + fehler.Message, "Auftragsbuch", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
Einstellungen kapselt App.config. Fast jede Klasse des Programms greift darauf zu (B2).
using System.Configuration;
using System.Globalization;
namespace Brandt.Auftragsbuch
{
// BRUCH B2: Alles kommt aus App.config, gelesen über System.Configuration.
internal static class Einstellungen
{
public static string Verbindung
{
get { return ConfigurationManager.ConnectionStrings["Auftragsbuch"].ConnectionString; }
}
public static string ExportOrdner { get { return Text("ExportOrdner"); } }
public static string GruppeDisposition { get { return Text("GruppeDisposition"); } }
public static decimal Stundensatz { get { return Zahl("Stundensatz"); } }
public static decimal Anfahrtspauschale { get { return Zahl("Anfahrtspauschale"); } }
public static decimal Mwst { get { return Zahl("Mwst"); } }
private static string Text(string name)
{
return ConfigurationManager.AppSettings[name];
}
private static decimal Zahl(string name)
{
return decimal.Parse(Text(name), CultureInfo.InvariantCulture);
}
}
}
Berechtigung fragt Windows, wer angemeldet ist und ob die Person in der Gruppe der Disposition ist (B3). Die Oberfläche macht danach
Schaltflächen und Spalten schreibgeschützt, das ist die ganze Rechteprüfung. Auf Ihrem Rechner gibt es die Gruppe BRANDT\Disposition
nicht; deshalb sind die Formulare zunächst schreibgeschützt. Für die Übung machen Sie den Wert in App.config leer, dann darf jede
Person alles (nur für die lokale Entwicklung gedacht).
using System.Security.Principal;
namespace Brandt.Auftragsbuch
{
// BRUCH B3: Wer ist das, und in welcher Gruppe? Das beantwortet Windows, still und ohne Anmeldeseite.
internal static class Berechtigung
{
public static string Benutzer
{
get { return WindowsIdentity.GetCurrent().Name; }
}
// Disposition darf bearbeiten; alle anderen lesen nur. Ohne Gruppe in der Konfiguration darf jeder (lokale Entwicklung).
public static bool DarfBearbeiten
{
get
{
string gruppe = Einstellungen.GruppeDisposition;
if (string.IsNullOrEmpty(gruppe))
{
return true;
}
return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(gruppe);
}
}
}
}
Schritt 4: der Datenzugriff
AuftragsDaten hält ein DataSet mit drei Tabellen und einer Beziehung vom Kunden zu seinen Aufträgen und Wartungen. Drei
SqlDataAdapter füllen es, ein SqlCommandBuilder erzeugt INSERT, UPDATE und DELETE (und vergleicht beim Ändern alle alten Werte,
das ist die „optimistische Sperre“). Die Formulare binden direkt an diese Tabellen (B4).
Der Code enthält drei Stellen, die Sie bei DataSets immer wieder finden und die erklärt werden müssen:
- Vorläufige Nummern. Neue Zeilen bekommen negative Nummern (
AutoIncrementSeed = -1), damit sie nie mit echten kollidieren. Die echte Nummer vergibt die Datenbank;NeueNummerLesenholt sie nach jedem INSERT und trägt sie in die Zeile ein. Dabei ist@@IDENTITYrichtig undSCOPE_IDENTITY()falsch, denn der Befehl läuft in einem neuen Gültigkeitsbereich auf derselben Verbindung. - Vorgaben der Datenbank kennt das DataSet nicht.
StundenundMaterialhaben in der Datenbank den Vorgabewert 0, das DataSet verlangt trotzdem einen Wert. Deshalb setztWartungsauftragAnlegenbeide. - Nach dem Speichern wird neu geladen. Der Trigger der Datenbank kann Daten ändern, die das DataSet nicht kennt.
using System;
using System.Data;
using System.Data.SqlClient;
namespace Brandt.Auftragsbuch
{
// BRUCH B4: Ein DataSet mit Adaptern, direkt an die Formulare gebunden. Es gibt keine Schicht dazwischen.
// BRUCH B11: Alle Aufrufe laufen auf dem Oberflächenthread; Program.Daten ist für alle Formulare derselbe Zustand.
public sealed class AuftragsDaten
{
private readonly string _verbindung;
private readonly SqlDataAdapter _kunden;
private readonly SqlDataAdapter _wartung;
private readonly SqlDataAdapter _auftraege;
public AuftragsDaten(string verbindung)
{
_verbindung = verbindung;
_kunden = Adapter("SELECT KundeId, Name, Strasse, Plz, Ort, Telefon, Notiz FROM dbo.Kunden ORDER BY Name", true);
_wartung = Adapter("SELECT WartungId, KundeId, Anlage, IntervallMonate, LetzteWartung FROM dbo.Wartungsplan", false);
_auftraege = Adapter(
"SELECT AuftragId, KundeId, WartungId, Titel, Art, Status, Termin, Stunden, Material, Rechnungsnummer " +
"FROM dbo.Auftraege ORDER BY AuftragId",
true);
}
public DataSet Daten { get; } = new DataSet("Auftragsbuch");
public void Laden()
{
if (Daten.Tables.Count == 0)
{
Schema(_kunden, "Kunden");
Schema(_wartung, "Wartungsplan");
Schema(_auftraege, "Auftraege");
Daten.Relations.Add("Kunde_Auftraege", Daten.Tables["Kunden"].Columns["KundeId"], Daten.Tables["Auftraege"].Columns["KundeId"]);
Daten.Relations.Add("Kunde_Wartungsplan", Daten.Tables["Kunden"].Columns["KundeId"], Daten.Tables["Wartungsplan"].Columns["KundeId"]);
}
Daten.Clear();
_kunden.Fill(Daten, "Kunden");
_wartung.Fill(Daten, "Wartungsplan");
_auftraege.Fill(Daten, "Auftraege");
}
// Der Trigger der Datenbank kann LetzteWartung ändern; deshalb danach alles neu lesen.
public void Speichern()
{
_kunden.Update(Daten.Tables["Kunden"]);
_auftraege.Update(Daten.Tables["Auftraege"]);
Laden();
}
// BRUCH B6: Die Regel „fällig“ steht in einer gespeicherten Prozedur.
public DataTable WartungFaellig(DateTime bis)
{
var tabelle = new DataTable("WartungFaellig");
using (var befehl = new SqlCommand("dbo.WartungFaellig", new SqlConnection(_verbindung)))
{
befehl.CommandType = CommandType.StoredProcedure;
befehl.Parameters.AddWithValue("@Bis", bis);
using (var adapter = new SqlDataAdapter(befehl))
{
adapter.Fill(tabelle);
}
}
return tabelle;
}
public void WartungsauftragAnlegen(int wartungId, int kundeId, string anlage, DateTime termin)
{
DataRow zeile = Daten.Tables["Auftraege"].NewRow();
zeile["KundeId"] = kundeId;
zeile["WartungId"] = wartungId;
zeile["Titel"] = "Wartung: " + anlage;
zeile["Art"] = "Wartung";
zeile["Status"] = "Geplant";
zeile["Termin"] = termin;
zeile["Stunden"] = 0m;
zeile["Material"] = 0m;
Daten.Tables["Auftraege"].Rows.Add(zeile);
Speichern();
}
// Neue Zeilen bekommen vorläufige, negative Nummern; die echte vergibt erst die Datenbank.
private void Schema(SqlDataAdapter adapter, string tabelle)
{
adapter.FillSchema(Daten, SchemaType.Source, tabelle);
DataColumn nummer = Daten.Tables[tabelle].Columns[0];
nummer.ReadOnly = false;
nummer.AutoIncrementSeed = -1;
nummer.AutoIncrementStep = -1;
}
private SqlDataAdapter Adapter(string abfrage, bool schreibend)
{
var adapter = new SqlDataAdapter(abfrage, _verbindung);
if (schreibend)
{
// Der CommandBuilder erzeugt INSERT, UPDATE und DELETE und vergleicht beim Ändern alle alten Werte.
new SqlCommandBuilder(adapter);
adapter.RowUpdated += NeueNummerLesen;
}
return adapter;
}
// Nach einem INSERT holt sich die Zeile die neue Nummer der Datenbank. Es ist ein neuer Befehl auf derselben
// Verbindung, deshalb @@IDENTITY: SCOPE_IDENTITY() läge in einem anderen Gültigkeitsbereich und gäbe NULL zurück.
private static void NeueNummerLesen(object sender, SqlRowUpdatedEventArgs e)
{
if (e.StatementType != StatementType.Insert)
{
return;
}
using (var befehl = new SqlCommand("SELECT CAST(@@IDENTITY AS int)", e.Command.Connection, e.Command.Transaction))
{
e.Row[0] = befehl.ExecuteScalar();
}
}
}
}
Schritt 5: die Formulare
Die Steuerelemente entstehen im Code statt im Designer, damit alle Dateien in dieser Lektion stehen. Ein Designer würde dieselbe
Arbeit in einer *.Designer.cs erledigen. Wichtig sind die Stellen, die später etwas berühren.
Das Hauptformular zeigt die Aufträge in einem Gitter, das an eine DataView des DataSets gebunden ist, und bietet Speichern,
Rechnungsdruck und Export. Es enthält außerdem die Preisregeln des Betriebs (B5): mindestens eine Stunde Arbeit, keine Anfahrt für
Wartungen, 15 Prozent Aufschlag auf Material, Mehrwertsteuer aus der Konfiguration. Frau Aydin kennt diese Regeln, aber sie stehen nur
hier, in vier kleinen Methoden eines Formulars, und der Rechnungsdruck ruft sie dort ab. Die Schrift ist fest eingestellt (B10), und die
Fenstergröße landet in der Windows-Registrierung (B12).
using System;
using System.Data;
using System.Data.SqlClient;
using System.Drawing;
using System.Windows.Forms;
using Microsoft.Win32;
namespace Brandt.Auftragsbuch
{
public sealed class HauptForm : Form
{
private const string RegistrySchluessel = @"Software\Brandt\Auftragsbuch";
private readonly DataView _ansicht;
private readonly DataGridView _tabelle = new DataGridView { Dock = DockStyle.Fill, AutoGenerateColumns = false };
private readonly ComboBox _filter = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 140 };
private readonly Button _speichern = new Button { Text = "Speichern", AutoSize = true };
private readonly Button _rechnung = new Button { Text = "Rechnung drucken", AutoSize = true };
private readonly ToolStripMenuItem _exportieren = new ToolStripMenuItem("Export an Buchhaltung");
public HauptForm()
{
// BRUCH B10: Feste Schrift und Schriftgröße; das Layout verlässt sich auf die Maße dieser Schrift.
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(960, 480);
Text = "Auftragsbuch - Brandt Haustechnik (" + Berechtigung.Benutzer + ")";
_ansicht = new DataView(Program.Daten.Daten.Tables["Auftraege"]);
_tabelle.DataSource = _ansicht;
SpaltenAnlegen();
_filter.Items.AddRange(new object[] { "Alle", "Neu", "Geplant", "Erledigt", "Abgerechnet" });
_filter.SelectedIndex = 0;
_filter.SelectedIndexChanged += (s, e) =>
_ansicht.RowFilter = _filter.SelectedIndex <= 0 ? string.Empty : "Status = '" + _filter.Text + "'";
_speichern.Click += (s, e) => Speichern();
_rechnung.Click += (s, e) => RechnungDrucken();
_tabelle.DefaultValuesNeeded += StandardWerte;
_tabelle.DataError += (s, e) =>
{
MessageBox.Show(this, "Bitte Kunde und Titel angeben.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
e.ThrowException = false;
};
var leiste = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true };
leiste.Controls.AddRange(new Control[] { new Label { Text = "Status:", AutoSize = true }, _filter, _speichern, _rechnung });
Controls.Add(_tabelle);
Controls.Add(leiste);
Controls.Add(MenueAnlegen());
bool darfBearbeiten = Berechtigung.DarfBearbeiten;
_tabelle.ReadOnly = !darfBearbeiten;
_tabelle.AllowUserToAddRows = darfBearbeiten;
_speichern.Enabled = darfBearbeiten;
_rechnung.Enabled = darfBearbeiten;
_exportieren.Enabled = darfBearbeiten;
Load += (s, e) => FensterLesen();
FormClosing += (s, e) => FensterSchreiben();
}
// BRUCH B5: Die Preisregeln des Betriebs stehen im Formular; der Rechnungsdruck ruft sie hier ab.
public static decimal Arbeit(DataRow auftrag)
{
decimal stunden = Math.Max(Convert.ToDecimal(auftrag["Stunden"]), 1m);
return stunden * Einstellungen.Stundensatz;
}
public static decimal Anfahrt(DataRow auftrag)
{
return (string)auftrag["Art"] == "Wartung" ? 0m : Einstellungen.Anfahrtspauschale;
}
public static decimal MaterialMitAufschlag(DataRow auftrag)
{
return Math.Round(Convert.ToDecimal(auftrag["Material"]) * 1.15m, 2);
}
public static decimal Netto(DataRow auftrag)
{
return Arbeit(auftrag) + Anfahrt(auftrag) + MaterialMitAufschlag(auftrag);
}
public static decimal Brutto(DataRow auftrag)
{
return Math.Round(Netto(auftrag) * (1 + Einstellungen.Mwst), 2);
}
private MenuStrip MenueAnlegen()
{
var datei = new ToolStripMenuItem("Datei");
datei.DropDownItems.Add("Rechnung drucken", null, (s, e) => RechnungDrucken());
_exportieren.Click += (s, e) => Exportieren();
datei.DropDownItems.Add(_exportieren);
datei.DropDownItems.Add("Beenden", null, (s, e) => Close());
var ansicht = new ToolStripMenuItem("Ansicht");
ansicht.DropDownItems.Add("Kundenstamm", null, (s, e) => new KundenForm().ShowDialog(this));
ansicht.DropDownItems.Add("Wartungsplan", null, (s, e) => new WartungsForm().ShowDialog(this));
var menue = new MenuStrip { Dock = DockStyle.Top };
menue.Items.AddRange(new ToolStripItem[] { datei, ansicht });
MainMenuStrip = menue;
return menue;
}
private void SpaltenAnlegen()
{
DataTable kunden = Program.Daten.Daten.Tables["Kunden"];
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "AuftragId", HeaderText = "Nr.", ReadOnly = true, Width = 50 });
_tabelle.Columns.Add(new DataGridViewComboBoxColumn
{
DataPropertyName = "KundeId",
HeaderText = "Kunde",
DataSource = kunden,
DisplayMember = "Name",
ValueMember = "KundeId",
Width = 190
});
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Titel", HeaderText = "Titel", Width = 220 });
_tabelle.Columns.Add(Auswahl("Art", "Art", "Heizung", "Sanitaet", "Wartung"));
_tabelle.Columns.Add(Auswahl("Status", "Status", "Neu", "Geplant", "Erledigt", "Abgerechnet"));
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Termin", HeaderText = "Termin", Width = 80 });
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Stunden", HeaderText = "Std.", Width = 45 });
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Material", HeaderText = "Material", Width = 70 });
_tabelle.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Rechnungsnummer", HeaderText = "Rechnung", ReadOnly = true, Width = 100 });
}
private static DataGridViewComboBoxColumn Auswahl(string feld, string titel, params string[] werte)
{
var spalte = new DataGridViewComboBoxColumn { DataPropertyName = feld, HeaderText = titel, Width = 90 };
spalte.Items.AddRange(werte);
return spalte;
}
private void StandardWerte(object sender, DataGridViewRowEventArgs e)
{
e.Row.Cells[3].Value = "Heizung";
e.Row.Cells[4].Value = "Neu";
e.Row.Cells[6].Value = 0m;
e.Row.Cells[7].Value = 0m;
}
private DataRow AktuelleZeile()
{
_tabelle.EndEdit();
BindingContext[_ansicht].EndCurrentEdit();
var zeile = _tabelle.CurrentRow == null ? null : _tabelle.CurrentRow.DataBoundItem as DataRowView;
return zeile == null ? null : zeile.Row;
}
private bool Speichern()
{
try
{
AktuelleZeile();
Program.Daten.Speichern();
return true;
}
catch (DBConcurrencyException)
{
MessageBox.Show(this, "Jemand hat diesen Auftrag inzwischen geändert. Die Liste wird neu geladen.", Text);
Program.Daten.Laden();
}
catch (SqlException ex)
{
MessageBox.Show(this, "Speichern nicht möglich: " + ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
return false;
}
private void RechnungDrucken()
{
DataRow auftrag = AktuelleZeile();
if (auftrag == null || auftrag.RowState == DataRowState.Added)
{
MessageBox.Show(this, "Bitte zuerst einen gespeicherten Auftrag wählen.", Text);
return;
}
if ((string)auftrag["Status"] == "Neu" || (string)auftrag["Status"] == "Geplant")
{
MessageBox.Show(this, "Der Auftrag ist noch nicht erledigt.", Text);
return;
}
if (auftrag.IsNull("Rechnungsnummer"))
{
auftrag["Rechnungsnummer"] = string.Format("R-{0}-{1:D5}", DateTime.Today.Year, auftrag["AuftragId"]);
auftrag["Status"] = "Abgerechnet";
if (!Speichern())
{
return;
}
}
using (var vorschau = new PrintPreviewDialog { Document = new Rechnungsdruck(auftrag), WindowState = FormWindowState.Maximized })
{
vorschau.ShowDialog(this);
}
}
private void Exportieren()
{
try
{
string rechnungen = Exporte.RechnungenAlsCsv(Program.Daten.Daten);
string wartung = Exporte.WartungsplanAlsJson(Program.Daten.Daten);
MessageBox.Show(this, "Geschrieben:\n" + rechnungen + "\n" + wartung, Text);
}
catch (System.IO.IOException ex)
{
MessageBox.Show(this, "Das Netzlaufwerk ist nicht erreichbar: " + ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// BRUCH B12: Fenstermaße in der Windows-Registrierung des Benutzers.
private void FensterLesen()
{
using (RegistryKey schluessel = Registry.CurrentUser.OpenSubKey(RegistrySchluessel))
{
if (schluessel == null)
{
return;
}
ClientSize = new Size((int)schluessel.GetValue("Breite", ClientSize.Width), (int)schluessel.GetValue("Hoehe", ClientSize.Height));
}
}
private void FensterSchreiben()
{
using (RegistryKey schluessel = Registry.CurrentUser.CreateSubKey(RegistrySchluessel))
{
schluessel.SetValue("Breite", ClientSize.Width);
schluessel.SetValue("Hoehe", ClientSize.Height);
}
}
}
}
Der Kundenstamm zeigt die Kundentabelle und daneben eine Kundenkarte in einem WebBrowser-Steuerelement (B9): eine kleine
HTML-Seite mit Adresse, Link zur Karte und den Aufträgen des Kunden, erzeugt aus der DataSet-Beziehung.
using System;
using System.Data;
using System.Drawing;
using System.Net;
using System.Text;
using System.Windows.Forms;
namespace Brandt.Auftragsbuch
{
public sealed class KundenForm : Form
{
private readonly DataGridView _tabelle = new DataGridView { Dock = DockStyle.Left, Width = 480, AutoGenerateColumns = true };
// BRUCH B9: Das Browser-Steuerelement WebBrowser zeigt die Kundenkarte und den Link zur Karte.
private readonly WebBrowser _karte = new WebBrowser { Dock = DockStyle.Fill, ScriptErrorsSuppressed = true };
public KundenForm()
{
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(900, 420);
Text = "Kundenstamm";
_tabelle.DataSource = Program.Daten.Daten.Tables["Kunden"];
_tabelle.ReadOnly = !Berechtigung.DarfBearbeiten;
_tabelle.SelectionChanged += (s, e) => KarteZeigen();
_tabelle.DataBindingComplete += (s, e) => _tabelle.Columns["KundeId"].Visible = false;
var speichern = new Button { Text = "Speichern", Dock = DockStyle.Bottom, Enabled = Berechtigung.DarfBearbeiten };
speichern.Click += (s, e) => Program.Daten.Speichern();
Controls.Add(_karte);
Controls.Add(_tabelle);
Controls.Add(speichern);
}
private void KarteZeigen()
{
var zeile = _tabelle.CurrentRow == null ? null : _tabelle.CurrentRow.DataBoundItem as DataRowView;
if (zeile == null)
{
return;
}
DataRow kunde = zeile.Row;
var html = new StringBuilder("<html><body style=\"font-family:Segoe UI\">");
html.Append("<h2>").Append(WebUtility.HtmlEncode((string)kunde["Name"])).Append("</h2>");
html.Append("<p>").Append(WebUtility.HtmlEncode((string)kunde["Strasse"])).Append("<br>");
html.Append(WebUtility.HtmlEncode(kunde["Plz"] + " " + kunde["Ort"])).Append("</p>");
html.Append("<p><a href=\"https://www.openstreetmap.org/search?query=")
.Append(Uri.EscapeDataString(kunde["Strasse"] + ", " + kunde["Plz"] + " " + kunde["Ort"]))
.Append("\">Auf der Karte zeigen</a></p><h3>Aufträge</h3><ul>");
foreach (DataRow auftrag in kunde.GetChildRows("Kunde_Auftraege"))
{
html.Append("<li>").Append(WebUtility.HtmlEncode(auftrag["Titel"] + " (" + auftrag["Status"] + ")")).Append("</li>");
}
html.Append("</ul></body></html>");
_karte.DocumentText = html.ToString();
}
}
}
Der Wartungsplan ruft die gespeicherte Prozedur auf und legt Aufträge für fällige Wartungen an.
using System;
using System.Data;
using System.Drawing;
using System.Windows.Forms;
namespace Brandt.Auftragsbuch
{
public sealed class WartungsForm : Form
{
private readonly DateTimePicker _bis = new DateTimePicker { Format = DateTimePickerFormat.Short, Value = DateTime.Today.AddDays(30) };
private readonly DataGridView _tabelle = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect
};
public WartungsForm()
{
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(760, 380);
Text = "Wartungsplan";
var anlegen = new Button { Text = "Auftrag anlegen", AutoSize = true, Enabled = Berechtigung.DarfBearbeiten };
var exportieren = new Button { Text = "Plan für Monteure exportieren", AutoSize = true };
var leiste = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true };
leiste.Controls.AddRange(new Control[] { new Label { Text = "Fällig bis:", AutoSize = true }, _bis, anlegen, exportieren });
_bis.ValueChanged += (s, e) => Laden();
anlegen.Click += (s, e) => AuftragAnlegen();
exportieren.Click += (s, e) => MessageBox.Show(this, Exporte.WartungsplanAlsJson(Program.Daten.Daten), Text);
Controls.Add(_tabelle);
Controls.Add(leiste);
Load += (s, e) => Laden();
}
private void Laden()
{
_tabelle.DataSource = Program.Daten.WartungFaellig(_bis.Value.Date);
foreach (string spalte in new[] { "WartungId", "KundeId" })
{
_tabelle.Columns[spalte].Visible = false;
}
}
private void AuftragAnlegen()
{
var zeile = _tabelle.CurrentRow == null ? null : _tabelle.CurrentRow.DataBoundItem as DataRowView;
if (zeile == null)
{
return;
}
Program.Daten.WartungsauftragAnlegen(
(int)zeile["WartungId"],
(int)zeile["KundeId"],
(string)zeile["Anlage"],
(DateTime)zeile["Naechste"]);
Laden();
}
}
}
Der Rechnungsdruck zeichnet die Rechnung Zeile für Zeile mit Graphics auf das Papier (B7), über ein PrintDocument, das die
Druckvorschau ebenfalls versteht.
using System.Data;
using System.Drawing;
using System.Drawing.Printing;
using System.Globalization;
namespace Brandt.Auftragsbuch
{
// BRUCH B7: PrintDocument und System.Drawing; die Rechnung wird Zeile für Zeile mit Graphics gezeichnet.
internal sealed class Rechnungsdruck : PrintDocument
{
private readonly DataRow _auftrag;
public Rechnungsdruck(DataRow auftrag)
{
_auftrag = auftrag;
DocumentName = "Rechnung " + auftrag["Rechnungsnummer"];
}
protected override void OnPrintPage(PrintPageEventArgs e)
{
DataRow kunde = _auftrag.GetParentRow("Kunde_Auftraege");
Graphics g = e.Graphics;
float x = e.MarginBounds.Left;
float y = e.MarginBounds.Top;
using (var gross = new Font("Arial", 16, FontStyle.Bold))
using (var normal = new Font("Arial", 10))
using (var rechts = new StringFormat { Alignment = StringAlignment.Far })
{
float zeile = normal.GetHeight(g) * 1.5f;
g.DrawString("Brandt Haustechnik", gross, Brushes.Black, x, y);
y += gross.GetHeight(g) * 2;
g.DrawString((string)kunde["Name"], normal, Brushes.Black, x, y);
g.DrawString((string)kunde["Strasse"], normal, Brushes.Black, x, y + zeile);
g.DrawString(kunde["Plz"] + " " + kunde["Ort"], normal, Brushes.Black, x, y + 2 * zeile);
y += 4 * zeile;
g.DrawString("Rechnung " + _auftrag["Rechnungsnummer"], gross, Brushes.Black, x, y);
y += gross.GetHeight(g) * 1.5f;
g.DrawString((string)_auftrag["Titel"], normal, Brushes.Black, x, y);
y += 2 * zeile;
Position(g, normal, rechts, ref y, x, zeile, "Arbeit", HauptForm.Arbeit(_auftrag));
Position(g, normal, rechts, ref y, x, zeile, "Anfahrt", HauptForm.Anfahrt(_auftrag));
Position(g, normal, rechts, ref y, x, zeile, "Material", HauptForm.MaterialMitAufschlag(_auftrag));
Position(g, normal, rechts, ref y, x, zeile, "Netto", HauptForm.Netto(_auftrag));
Position(g, normal, rechts, ref y, x, zeile, "Brutto", HauptForm.Brutto(_auftrag));
}
e.HasMorePages = false;
}
private static void Position(Graphics g, Font schrift, StringFormat rechts, ref float y, float x, float zeile, string text, decimal betrag)
{
g.DrawString(text, schrift, Brushes.Black, x, y);
g.DrawString(betrag.ToString("N2", CultureInfo.CurrentCulture), schrift, Brushes.Black, new RectangleF(x + 250, y, 150, zeile), rechts);
y += zeile;
}
}
}
Der Export schreibt zwei Dateien in den Ordner auf dem Netzlaufwerk (B8): eine CSV-Datei für die Buchhaltung in der Windows-Kodierung 1252 und eine JSON-Datei mit dem Wartungsplan für das Tablet der Monteure. Die Regel „nächste Wartung“ steht hier ein zweites Mal, neben der Prozedur der Datenbank (B6).
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.IO;
using System.Text;
using Newtonsoft.Json;
namespace Brandt.Auftragsbuch
{
// BRUCH B8: Beide Dateien landen in einem Ordner auf dem Netzlaufwerk; andere Programme lesen sie dort.
internal static class Exporte
{
private static readonly CultureInfo Deutsch = new CultureInfo("de-DE");
// Die Buchhaltung liest Semikolon-Dateien in Windows-1252.
public static string RechnungenAlsCsv(DataSet daten)
{
Directory.CreateDirectory(Einstellungen.ExportOrdner);
string pfad = Path.Combine(Einstellungen.ExportOrdner, "rechnungen-" + DateTime.Today.ToString("yyyy-MM-dd") + ".csv");
var zeilen = new List<string> { "Rechnungsnummer;Kunde;Titel;Netto;Brutto" };
foreach (DataRow auftrag in daten.Tables["Auftraege"].Select("Status = 'Abgerechnet'", "Rechnungsnummer"))
{
decimal netto = HauptForm.Netto(auftrag);
decimal brutto = HauptForm.Brutto(auftrag);
zeilen.Add(string.Join(";", new[]
{
(string)auftrag["Rechnungsnummer"],
(string)auftrag.GetParentRow("Kunde_Auftraege")["Name"],
(string)auftrag["Titel"],
netto.ToString("N2", Deutsch),
brutto.ToString("N2", Deutsch)
}));
}
File.WriteAllLines(pfad, zeilen, Encoding.GetEncoding(1252));
return pfad;
}
// Die Monteure lesen den Plan auf ihrem Tablet aus dieser JSON-Datei.
public static string WartungsplanAlsJson(DataSet daten)
{
Directory.CreateDirectory(Einstellungen.ExportOrdner);
string pfad = Path.Combine(Einstellungen.ExportOrdner, "wartungsplan.json");
var eintraege = new List<object>();
foreach (DataRow zeile in daten.Tables["Wartungsplan"].Rows)
{
// BRUCH B6: Dieselbe Regel wie in dbo.WartungFaellig, ein zweites Mal geschrieben.
DateTime naechste = ((DateTime)zeile["LetzteWartung"]).AddMonths((int)zeile["IntervallMonate"]);
eintraege.Add(new
{
kunde = (string)zeile.GetParentRow("Kunde_Wartungsplan")["Name"],
anlage = (string)zeile["Anlage"],
naechste = naechste.ToString("yyyy-MM-dd")
});
}
File.WriteAllText(pfad, JsonConvert.SerializeObject(eintraege, Formatting.Indented), new UTF8Encoding(false));
return pfad;
}
}
}
Schritt 6: bauen und starten
Ein Paket aus packages.config stellen Visual Studio und MSBuild wieder her. In der Developer PowerShell von Visual Studio:
msbuild Brandt.Auftragsbuch.sln -restore -p:RestorePackagesConfig=true
echo $LASTEXITCODE
Brandt.Auftragsbuch -> ...\Brandt.Auftragsbuch\bin\Debug\Brandt.Auftragsbuch.exe
0
Starten Sie Brandt.Auftragsbuch\bin\Debug\Brandt.Auftragsbuch.exe. Sie sehen ein Fenster mit einer Auftragsliste, darüber ein Menü
(Datei, Ansicht) und eine Leiste mit Filter, Speichern und „Rechnung drucken“. Der Kundenstamm öffnet sich über „Ansicht“.
Die Bruchstellen
In den Kommentaren steht, wo etwas bricht, wenn das Programm auf .NET 10, ins Web oder nach Azure wandert. Eine Suche zeigt alle:
Select-String -Path .\Brandt.Auftragsbuch\*.cs,.\Brandt.Auftragsbuch\*.csproj -Pattern 'BRUCH B' |
ForEach-Object { "{0}:{1} {2}" -f $_.Filename, $_.LineNumber, $_.Line.Trim() }
| Nr. | Wo | Was bricht oder muss sich ändern | Lektion |
|---|---|---|---|
| B1 | .csproj, packages.config |
klassisches Projektformat, Pakete aus packages.config |
M2.2 |
| B2 | Einstellungen, App.config |
System.Configuration und App.config |
M2.4 |
| B3 | Berechtigung |
Windows-Anmeldung und Gruppen | M5.1, M5.2 |
| B4 | AuftragsDaten und alle Formulare |
DataSet, Adapter und CommandBuilder, direkt gebunden | M3.2 |
| B5 | HauptForm |
Preisregeln im Formular, vom Druck abgerufen | M3.1 |
| B6 | Prozedur, Trigger, Exporte |
die Regel „nächste Wartung“ lebt in der Datenbank und ein zweites Mal im Code | M3.2, M3.3 |
| B7 | Rechnungsdruck |
PrintDocument und System.Drawing |
M2.3, M4.5 |
| B8 | Exporte, App.config |
Netzlaufwerk, Dateikodierung 1252 | M2.3, M4.5 |
| B9 | KundenForm |
WebBrowser-Steuerelement |
M2.3, M4.4 |
| B10 | alle Formulare | feste Schrift und Maße, Layout hängt an der Schrift | M2.3 |
| B11 | Program, AuftragsDaten |
globaler Zustand, blockierende Aufrufe im Oberflächenthread | M4.2, M4.3 |
| B12 | HauptForm, Program |
Registry und Ereignisprotokoll | M2.3 |
Was kann schiefgehen
- Die Datenbank ist nicht erreichbar. Beim ersten Zugriff auf LocalDB muss die Instanz erst angelegt und gestartet werden; Microsofts Dokumentation sagt, dass der Verbindungsversuch dabei in ein Zeitlimit laufen kann. Warten Sie einige Sekunden und versuchen Sie es erneut.
- Alles ist schreibgeschützt. Die Gruppe aus
GruppeDispositiongibt es auf Ihrem Rechner nicht. Leeren Sie den Wert inApp.config. - Der Build findet
Newtonsoft.Jsonnicht. Das Paket wurde nicht wiederhergestellt: Bauen Sie mit-restoreund-p:RestorePackagesConfig=true, oder in Visual Studio über „NuGet-Pakete wiederherstellen“. - Der Export meldet, das Netzlaufwerk sei nicht erreichbar. Setzen Sie
ExportOrdnerauf einen lokalen Ordner. Die Stelle bleibt dieselbe: Der Code kennt nur einen Pfad. - Die Spalte
KundeIdist schreibgeschützt oder kollidiert. Dann fehlt die Einstellung der vorläufigen Nummern inSchema. - Speichern meldet, jemand habe den Auftrag geändert. Zwei Fenster haben denselben Auftrag bearbeitet; der CommandBuilder erkennt die Änderung, weil er alle alten Werte vergleicht. Das ist gewollt.
Übung 1
Bauen Sie das Auftragsbuch auf: Datenbank anlegen, Dateien anlegen, bauen, starten. Setzen Sie vorher in App.config die Gruppe
leer und ExportOrdner auf einen lokalen Ordner Ihrer Wahl. Legen Sie nach jedem Schritt der Lektion einen Commit an. Gehen Sie dann
einmal durch das Programm: Legen Sie einen Auftrag für „Frau Petersen“ an, setzen Sie ihn auf „Erledigt“, speichern Sie und drucken
Sie die Rechnung in der Vorschau. Öffnen Sie den Kundenstamm und den Wartungsplan, legen Sie einen Wartungsauftrag an und exportieren
Sie.
So prüfen Sie Ihr Ergebnis: Der Build endet mit dem Exit-Code 0, der Auftrag steht nach dem Neustart noch in der Liste, und im
Ordner liegen rechnungen-<Datum>.csv und wartungsplan.json.
Lösung 1
Ein Auftrag von „Frau Petersen“ mit 2,25 Stunden und 48,90 Material ergibt mit den Werten aus App.config (Stundensatz 68, Anfahrt
29,50, Mehrwertsteuer 0,19) 238,74 netto und 284,10 brutto: 2,25 mal 68 sind 153, dazu 29,50 Anfahrt und 48,90 mal 1,15
(56,24 gerundet). Die Rechnungsvorschau zeigt diese Zeilen. Die CSV-Datei enthält die abgerechneten Aufträge:
Rechnungsnummer;Kunde;Titel;Netto;Brutto
R-SEED-00001;Bäckerei Sommer;Heizkörper im Verkaufsraum entlüften;145,30;172,91
Nach dem Erledigen eines Wartungsauftrags springt im Wartungsplan das Datum der letzten Wartung weiter. Das tut der Trigger der Datenbank, nicht das Programm. Wenn Sie es im Wartungsplan nicht sehen, laden Sie das Formular neu.
Zwei Fallen beim Prüfen: Die Nummer der Rechnung steht erst nach dem Speichern fest, deshalb verlangt das Formular einen gespeicherten
Auftrag. Und Frau Petersens Rechnung zeigt die Anfahrt, die der Wartungsauftrag nicht hat, weil die Regel Art == "Wartung"
ausnimmt.
Übung 2
Erstellen Sie die Liste der Bruchstellen selbst. Führen Sie die Suche aus dem Abschnitt „Die Bruchstellen“ aus und schreiben Sie
docs/bruchstellen.md: je Bruchstelle eine Zeile mit der Nummer, der Datei, einem Satz, was dort bricht, und der Lektion, in der Sie
es erwarten. Ergänzen Sie mindestens eine Stelle, die kein BRUCH-Kommentar markiert.
So prüfen Sie Ihr Ergebnis: Die Datei hat zwölf Zeilen plus Ihre Ergänzung, und jede Zeile nennt eine Datei, die es gibt.
Lösung 2
Die Suche liefert diese Zeilen (die Zeilennummern können bei Ihnen abweichen):
AuftragsDaten.cs:7 // BRUCH B4: Ein DataSet mit Adaptern, direkt an die Formulare gebunden. Es gibt keine Schicht dazwischen.
AuftragsDaten.cs:8 // BRUCH B11: Alle Aufrufe laufen auf dem Oberflächenthread; Program.Daten ist für alle Formulare derselbe Zustand.
AuftragsDaten.cs:54 // BRUCH B6: Die Regel „fällig“ steht in einer gespeicherten Prozedur.
Berechtigung.cs:5 // BRUCH B3: Wer ist das, und in welcher Gruppe? Das beantwortet Windows, still und ohne Anmeldeseite.
Einstellungen.cs:6 // BRUCH B2: Alles kommt aus App.config, gelesen über System.Configuration.
Exporte.cs:11 // BRUCH B8: Beide Dateien landen in einem Ordner auf dem Netzlaufwerk; andere Programme lesen sie dort.
Exporte.cs:50 // BRUCH B6: Dieselbe Regel wie in dbo.WartungFaellig, ein zweites Mal geschrieben.
HauptForm.cs:23 // BRUCH B10: Feste Schrift und Schriftgröße; das Layout verlässt sich auf die Maße dieser Schrift.
HauptForm.cs:65 // BRUCH B5: Die Preisregeln des Betriebs stehen im Formular; der Rechnungsdruck ruft sie hier ab.
HauptForm.cs:221 // BRUCH B12: Fenstermaße in der Windows-Registrierung des Benutzers.
KundenForm.cs:14 // BRUCH B9: Das Browser-Steuerelement WebBrowser zeigt die Kundenkarte und den Link zur Karte.
Program.cs:10 // BRUCH B11: Ein einziger, globaler Zustand für das ganze Programm.
Program.cs:34 // BRUCH B12: Fehler gehen in das Ereignisprotokoll von Windows.
Rechnungsdruck.cs:8 // BRUCH B7: PrintDocument und System.Drawing; die Rechnung wird Zeile für Zeile mit Graphics gezeichnet.
Brandt.Auftragsbuch.csproj:3 <!-- BRUCH B1: klassisches Projektformat, jede Datei einzeln aufgeführt, Pakete über packages.config -->
In docs/bruchstellen.md sind das die Zeilen der Tabelle oben. Stellen ohne Kommentar, die Sie ergänzen können:
- Die Form der Rechnungsnummer steht im Hauptformular, gebaut aus dem Jahr und der Auftragsnummer. Weder die Datenbank noch eine Fachklasse kennt diese Regel.
ScriptErrorsSuppressedim Kundenformular unterdrückt Skriptfehler des Browser-Steuerelements, statt sie zu beheben.StundenundMaterialhaben Vorgaben in der Datenbank, die das DataSet nicht kennt (sieheWartungsauftragAnlegen).
Was Sie jetzt können
- Sie haben das Auftragsbuch aufgebaut und wissen, wie ein Auftrag vom Formular über das DataSet in die Datenbank, den Druck und den Export gelangt.
- Sie kennen die zwölf Bruchstellen B1 bis B12 und die Lektionen, die sie bearbeiten.
- Sie haben den Stand in Ihrem Repository festgehalten und bauen mit einem Exit-Code, der zählt.
In der nächsten Lektion machen Sie eine Inventur: Was steckt im Programm, und was davon können Sie in zehn Minuten finden, ohne es zu lesen?
Weiterlesen
- SQL Server Express LocalDB: LocalDB starten und mit der automatischen Instanz verbinden.
- Overview of upgrading Windows Forms apps: was beim Anheben eines Windows-Forms-Programms auf .NET zu beachten ist; die Bruchstellen dieses Programms sind die Gründe dafür.
- Default templates for
dotnet new: die Vorlageslnund ihre Option--format. - Analyze your dependencies to port code from .NET Framework to .NET:
packages.configundPackageReference. - Overview of porting from .NET Framework to .NET: Windows-Desktop-Technologien und Windows-spezifische APIs.